Compare commits

...

554 Commits

Author SHA1 Message Date
github-actions[bot] b98787f836 chore: bump version to 4.3.0 [skip ci] 2026-09-20 13:56:50 +00:00
Maurice dbb98a9bcd Merge branch 'main' into dev 2026-09-20 15:29:35 +02:00
Maurice 8215fed2a7 Merge pull request #2406 from liketrek/fix/pr2273-final-audit
fix: the findings of the final 4.3.0 audit, and dual-stack hosts in the SSRF guard
2026-09-20 15:23:02 +02:00
Maurice 9731407694 feat(notices): document sync joins the release notice as a fourth, wide card
Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology Drive are a headliner
of this release and were missing from the notice. The card runs across the row
under the three upright ones, picture beside the text, so the panel keeps its
height: the wordmark without its API pill, the two-way arrow, and the five
stores as tiles. The panel's grid row is capped at the panel's height, so a
short viewport scrolls the left column instead of clipping the support buttons
on the right. The headline counts four, the intro and the card texts are
shorter, and the Dawarich card drops its last sentence, in all 23 languages.
The release e2e test asks with the bundle version, as the client does.
2026-09-20 14:58:33 +02:00
Maurice 8f6ed02cff fix(notices): hand the release notice only to a bundle built for the running version
After an update the service worker keeps serving the previous bundle until
the new one is installed. That shell announces the release layout too, so
the layout alone let it draw the notice with its own older texts and use it
up for the version now running with one click on X.

The bundle now sends the version it was built as (client/package.json, baked
in by define), and the server compares it with its own before it hands a
notice with a release block over. A shell of another version is told nothing
and gets the notice after the reload, from the bundle that can draw it. This
holds for every update, not only the one that introduced the parameter.
2026-09-20 14:28:45 +02:00
Maurice 31239f3687 fix(ssrf): connect over every address a name resolves to, IPv4 first
The guard resolved a name to one address and pinned the socket to it. On a
dual-stack name whose AAAA came first that was an IPv6 many hosts cannot
reach on 443, and the request sat until the caller's timeout while the A
record would have answered at once. OIDC discovery, Dawarich, AirTrail, the
photo providers, a self-hosted router or Nominatim and the model endpoints
all ride this path.

The name is now resolved to every address it has, each one is checked, and
one blocked address blocks the name. The whole checked list goes to the
socket, IPv4 ahead of IPv6, where Node walks it itself and moves on after
its attempt timeout. Redirect hops do the same per hop. A caller that still
names one address gets exactly the old behaviour.
2026-09-20 13:54:31 +02:00
Maurice ed18c37903 fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.

Data and credentials
- A document sync connection edited to a new address no longer carries the
  stored token along: a blank form under another origin is refused, the same
  rule the probe route already had. Dawarich and AirTrail testConnection send
  the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
  trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
  says that the booking and its expense go with it. Deleting a place says the
  same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
  and its attachments; a real revision carries the attachments over.

Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
  hidden hotel stop no longer shifts it one slot early; the store sorts before
  it splices. Via re-anchoring, the booked-night guard and the sidebar's day
  membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
  booking session receives the new stop order too. The Public API and the iCal
  feed list a booked hotel once. A plugin can no longer write a stay against
  another trip's day.

Road trip and map
- The route overview paces its requests to the public routers and retries a
  rate limit, and it says which legs it could not route instead of quietly
  reporting a shorter trip. A click on a via handle no longer drops a second
  via; handles hide when zooming out. Read-only members cannot edit the driving
  limits. The phone shows a way back when no day is picked and prints the night
  drive departure in the chosen clock format.

Integrations and search
- Google transit keeps conventional trains when the train chip is on.
  TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
  accepts the whole boolean family. A caller-supplied description no longer
  lands in the instance-wide enrichment cache. Nominatim searches carry a
  timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
  knows what the Atlas already shows, and a host change with a new key clears
  the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
  deleting a connection unregisters its subscriptions, Nextcloud under a
  sub-path connects, Synology answers a name collision before uploading and
  verifies a move into the bin, Paperless titles are capped at 128 characters,
  a DNS failure reads as unreachable rather than as a policy verdict, videos
  from the store are accepted like uploads, and remote_missing has a way out.
  Conflict buttons only for the owner, a refused choice is reported, a refused
  credential gets a Reconnect button, disconnecting asks first on the desktop
  as it does on the phone, and the folder picker no longer spins forever.

Release
- The server holds the release notice back from a bundle that cannot draw it,
  so the previous shell served from the service worker cache after an update
  no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
  images say when the limit is reached and the new controls are translated.
  The transit backend switch reports a failed save. The Helm chart carries the
  Amap credentials in its Secret; the shipped templates document the Places
  API and Amap variables and the Overpass default. The wiki follows.
2026-09-20 13:54:31 +02:00
Maurice 1e35a99c11 fix(memories): search the photo provider by the local day, not the UTC one
"This day" sent the entry's calendar date to the provider as both bounds, and
both providers turned that date into a UTC window — Immich by concatenating a
Z suffix, Synology by relying on a date-only string parsing as UTC midnight.
For a reader at UTC+10 the searched window was 10:00 on the day to 09:59 the
next morning, so every breakfast photo was missing while the next morning's
turned up in its place, and nothing said the day was incomplete. The date
headings had the mirror defect, filing a 07:32 photo under the previous day.

Immich publishes the right anchor already: localDateTime is required on every
search result and is what its own timeline groups by. The window is padded by
a day on each side and the page narrowed again by each photo's own local date,
carried through as localTakenAt; a server that does not send it falls back to
the timestamp used until now, so nothing changes there. Because the padding
means raw rows and answered rows are no longer the same thing, the page is
filled server-side until it holds what the caller asked for, bounded so a
pathological album cannot spin.

Synology has no local stamp, so its anchor comes from the caller as a UTC
offset, resolved for the day being searched rather than for today — a day
looked up across a DST boundary would otherwise start an hour out. The MCP
tool takes the same optional input, defaulting to the behaviour it has now.

The picker's pagination sentinel moved out of the non-empty branch: a page the
new filter empties has to keep paging, and it now says so instead of claiming
there are no photos.

Closes #2336
2026-09-20 12:48:44 +02:00
Maurice a782e0e471 fix(map): make stops that share a spot reachable again
A trip that models one building as several stops — drop the bags, check in,
the museum inside the hotel — drew every pin on the same pixel, and only the
top one could be clicked, hovered or seen. Clustering is the mechanism for
exactly that: the plugin keeps children no zoom level can separate in one
cluster and fans them apart on click. disableClusteringAtZoom pinned the
group's own maximum zoom to 8, which both removed every cluster above it and
moved the only fan trigger into a zoom band a trip planner never operates in,
so the spiderfyOnMaxZoom set beside it could never fire.

The cutoff is gone. maxClusterRadius is a function of zoom instead: the same
generous radius as before in the overview, a pin's width above it, so stops
that are genuinely apart keep their own pins and a pile becomes a counted
bubble that fans open. Selecting a buried stop from the places rail now opens
the bubble it is hiding in rather than doing nothing visible, and it does that
without moving the camera, which stays the selection's own concern.

The GL renderer has no fan to open, so folding a pin there removes the only
thing that was left rather than providing a way in. It folds at a far tighter
radius — genuine coincidence, where nothing was reachable to begin with — and
draws the selected member of a pile, so the rail reaches it there too. A map
object without a projector now skips folding instead of dropping every marker.

The public share page spreads the same options and has no rail to fall back
on, which made it the worse case; it is fixed by the same change.

Closes #2344
2026-09-20 12:48:44 +02:00
Maurice 7ccc8dc03d fix(booking-import): say why a recognised booking could not be imported
A document the model read correctly could still come back as "No reservations
could be extracted". Every type mapper can answer null when the node carries no
reservationFor, or one with no name, and both call sites pushed the item only
if there was one — with no else. The unknown-@type branch next to them does
warn, so a node TREK did not recognise was reported while a node it recognised
but could not map vanished: no item, no warning, nothing in the log. Because
the file did produce a node, the "no reservations found" fallback was skipped
too, and the preview arrived empty and silent. Every recognised type could
disappear this way, on both the kitinerary and the AI path.

Each of them now warns, naming the file, the index, the type and the reason.

Three smaller silences went with it. The debug line labelled "Raw LLM Response"
printed the already-parsed array, so its inspect formatting — single quotes,
bare keys — read as a provider emitting broken JSON and sent this report at the
JSON parser, which has handled that shape since 4.0.0. A single reservation
answered on its own, with no array and no wrapper, was dropped by the list
unwrapper although the schema downstream would have taken it. And a response
nobody could read returned an empty list, which is the same answer as a voucher
with no booking in it; it now raises, on the Anthropic client too, where a
reply cut off at the token limit produced exactly this report's symptom. The
plugin AI surface keeps its old empty answer, since a value is what a plugin
contracts for.

The retry ladder gained a rung that sends no response_format at all, for
providers that support neither json_schema nor json_object.

Closes #2375
2026-09-20 12:48:44 +02:00
Maurice 2a7ad1e241 fix(reservations): answer an id that resolves to nothing, instead of crashing
The scoping guard asked whether a referenced id belongs to a different trip.
An id that belongs to no row at all cannot, so it passed, reached the raw
INSERT/UPDATE, and SQLite's own foreign-key enforcement threw. Nothing caught
it, so a stale place_id from a client-side race came back as a bare 500 with no
actionable message — where the same route answers a clean 400 for an id that
does exist somewhere else.

A second, disjoint guard now names the ids that resolve to nothing and answers
400. It deliberately does not cover accommodation_id: that column has no row to
find once a shortened trip cascades its stay away, and refusing the write there
would make the booking permanently uneditable.

The controller guard reads the request body, which is not enough on its own —
an update rebinds whatever the row already held, so a stored id whose row is
gone reaches SQL untouched. day_id, end_day_id, place_id and assignment_id are
all declared ON DELETE SET NULL, so a reference that resolves to nothing is
precisely the state that cascade leaves behind and is now cleared to NULL, the
same way a dangling accommodation_id already heals. day_accommodations' day
columns are NOT NULL and have nothing to heal to, so those are refused.

The plugin surface ran neither guard. It now refuses an unresolvable id, which
turns a crash into an error; it still accepts an id from another trip, because
taking that away belongs to the plugin contract and not to a bug fix. The
reservation MCP tools already validated every referenced id against the trip.

Closes #2355
2026-09-20 12:48:44 +02:00
Maurice 4b3fb7dec9 fix(oidc): never take a role away over a claim the provider did not send
Role mapping read the configured OIDC_ADMIN_CLAIM out of the userinfo payload
and collapsed two very different answers into one: the claim arrived and did
not name an admin, and the claim was not there at all. The second is not a
verdict about the user — it is what an operator sees when the scope carrying
that claim was never requested, or the claim name has a typo. TREK wrote
role='user' for it anyway, with nothing in the log, nothing in the audit trail
and nothing in the UI, so a misconfiguration read as an unexplained loss of
admin. The only guard was the last-admin rule, which protects an instance with
exactly one admin row and therefore almost nobody.

An absent claim now leaves the stored role untouched. A claim that does arrive
still promotes and demotes exactly as before. To keep the silence from moving
somewhere worse, an absent claim on an account that is already an admin warns
on every login, naming the account: providers that omit a claim rather than
send it empty — Okta's filtered groups, Entra ID — cannot take admin away
through the mapping at all, and that has to be done in TREK's own admin panel.
The ordinary case still warns once per claim name, listing the claim names that
did arrive (never their values) and pointing at OIDC_SCOPE. A real transition
writes an audit row from the controller, with the client IP.

Closes #2364
2026-09-20 12:48:44 +02:00
Maurice 5d04f9c400 fix(docker): move start-up into a script so an edited container still boots
The image carried its whole start-up sequence as one inline `sh -c` string
containing four single-quoted echo messages. Container management UIs render
Config.Cmd back into a text field and re-split it on submit with a quote-aware,
escape-unaware tokenizer, so editing a container in place — the usual way to
change an environment variable — shredded the quoting and left dash half an
if-block to parse. The container then died before Node started, with only
`TREK: 1: Syntax error: end of file unexpected (expecting "fi")` in the log:
the word TREK is the stranded fragment of the first echo, reported as $0.

The logic moves to server/scripts/entrypoint.sh unchanged, and CMD becomes a
single bare token, which has nothing left to mangle. A unit test pins that
shape — both exec-form lines parse, and every argv element stays one token
free of quotes and spaces — and the Dockerfile joins the test workflow's path
filter so that guard actually runs when someone edits it.

Closes #2374
2026-09-20 12:48:44 +02:00
Maurice 62cccf32f3 fix(db): give clipped trips their days back and let road-trip day numbers follow the trip limit
Two boot migrations. The first appends the missing dated days to a trip
the old 365-day cap clipped, only where the dated days still run unbroken
from the start date, so a trip that was re-dated by hand or lost a day in
the middle is left alone; dateless days that hold content stay behind the
dated ones as generateDays keeps them. The second rebuilds
roadtrip_day_boundaries without the CHECK that held day_number to 366,
which would have refused a boundary on a later day of a longer trip.

The road-trip contracts follow the same limit: the day boundary and
corridor schemas, the set_day_boundary tool and the day window's runaway
guard read MAX_TRIP_DAYS instead of their own 365 or 366, and the
tooLong message carries the number as a placeholder in all 23 locales.
2026-09-20 12:34:34 +02:00
Maurice c8d4ed896e fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it
A trip longer than a year stopped at day 365: generateDays capped the day
rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last
days had a date but no day to plan on, in the day list, the transport and
booking dialogs and the phone's date pickers (#2403).

The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the
server, both trip forms and the MCP tools, and a range past it is refused
rather than cut short: 400 on REST, an error result on MCP, bad params on
the plugin RPC, and a message in the forms before anything is sent. The
check runs before the trip row and the budget are touched, also catches a
start date moved past the stored end on its own, and holds a day_count
rebuild to the same bound. A trip that already carries a longer range can
still be renamed. The MCP-only 90-day ceiling on create_trip goes with it;
it clipped dated trips the same way.

Closes #2403
2026-09-20 12:34:34 +02:00
Maurice 44c92052a3 docs(wiki): bring the pages in line with 4.3.0
Every page was read against the code on dev. What changed:

- Pages that described 4.2 behaviour: satellite view on the vector maps,
  the mobile journal timeline, Collab with its fifth tab, the Review badge,
  clustered pins on the share page, the Leaflet preset list, the strict
  guard on fe80::/10, the invite link tools and their scopes, the Public
  API scope rules, the permission count in the plugin SDK.
- Features of this release that no page mentioned: videos in a journal
  entry, receipts on an expense, the Google transit backend, routing
  counters, the rating on a place, the navigation menu, car stops, the
  manual school holiday catalogue and its MCP tools, the Dawarich panel in
  the Atlas, the release notes modal, plugin frames going fullscreen, the
  corridor call into the search provider hook.
- Statements that sent an admin the wrong way: DEFAULT_LANGUAGE is not in
  the Helm ConfigMap, env.PORT cannot be changed on the chart alone, the
  rotation script does not cover the document sync credentials, event
  handlers cannot broadcast, there is no per user MFA reset in the admin
  panel, the demo user can edit, an unparsable APP_URL stops the server.
- Updating gets a section for 4.3.0: the TREK Places API as the new
  default, the new variables, the Overpass timeout, SSO sessions on an
  OIDC only instance, the migration that puts booked nights on the drive.
2026-09-20 12:11:41 +02:00
Maurice 033735777a Merge pull request #2401 from liketrek/feat/collections-gpx
feat(collections): export and import a list as GPX
2026-09-20 11:10:34 +02:00
Maurice d803efd39d feat(collections): import a file into a list that already exists
The import dialog always made a new list. It now asks where the places go:
a new list, as before and still the default, or a list that is already there.
The picker offers the lists the person may edit, which is their own plus a
shared one where they are an editor or an admin, and starts on the list they
have open.

Adding to a list only ever adds. A place the list already holds is left
exactly as it is, with its status, notes, rating and labels, and counted as
already there in what the import reports; it is recognised the way a single
save recognises one, by provider id, then name, then position. The list keeps
its own name, colour, icon and description whatever the file says, a label
the file brings is created only when the list has none of that name, and new
places are appended after the ones already there so a manual order survives.

The route is POST /api/addons/collections/:id/import, 200 because it adds to
a list rather than making one, and both imports now write their places through
the same helper.
2026-09-19 23:12:25 +02:00
Maurice 7cfec6452d fix(ssrf): let a listed link-local address through, such as a rootless Podman host gateway
A rootless Podman container reaches its host through 169.254.1.2, and an
identity provider behind that address failed the OIDC login with "Requests
to link-local / cloud-metadata addresses are not allowed", with no setting
to change it.

ALLOW_LINK_LOCAL_IPS names single link-local IPv4 addresses that may be
reached after all. The admin-configured lane (OIDC, AI parsing, plugin
OAuth, routing engines) reaches a listed address directly; the guard on
user-supplied URLs treats it as internal, so it also needs
ALLOW_INTERNAL_NETWORK there. 169.254.169.0/24 and 169.254.170.0/24, where
clouds serve instance metadata and credentials, cannot be listed, and an
entry that is not one link-local IPv4 aborts startup. Unset, nothing
changes.

Closes #2400
2026-09-19 22:39:12 +02:00
Maurice 00de3c3a1d fix(mobile): offer public transit in the day plan's leg menu, as on the desktop
The connector menu on the phone listed only the road profiles, so the
transit search could not be opened for a single leg there. It now carries
the Public transit entry whenever the trip has dates, seeded with the leg's
two ends and the departure of the stop it leaves, and the transport sheet
passes that departure on to the search.

The prefill is built by one module the desktop day plan now uses as well.

Closes #2398
2026-09-19 22:39:05 +02:00
Maurice 3ad40e056d fix(docsync): an orphaned binding stays stopped on every path that starts a run 2026-09-19 21:57:04 +02:00
Maurice 83c9cebddc docs(wiki): a booked check-out no longer drives the road trip schedule 2026-09-19 21:48:08 +02:00
Maurice 58f6a49f32 docs(wiki): document road trip, document sync, the TREK Places API and Dawarich
The 4.3.0 features had no pages, or pages that described an earlier
state. Three new pages and a rewrite:

- Road-Trip: the addon from enabling it to the phone tab, stays and a
  stop's End as the time you leave, pinned times and the backward
  schedule, daily travel times, the three driving limits, search along
  the route, via points, other ways and what avoiding tolls needs,
  following a track, the warning layer, MCP tools and troubleshooting.
- Document-Sync: switching stores on, binding a trip, what each store
  asks for and binds, direction and delete rules, conflicts, the
  mass-delete guard, renames and restores, what needs a look, limits,
  private addresses, and the MCP tools.
- TREK-Places-API: what the index is, where TREK asks it, what leaves
  the instance, switching it off or pointing it at a copy of your own,
  and what happens when it is down.
- Dawarich: brought up to date with suggestions, the journal fold,
  recorded routes, Atlas proposals and the wish list.

Places and search, documents, the addon and admin overviews, MCP
tools, environment variables, internal network access, the sidebar and
home link the new pages, and the in-app help picks them up from the
sidebar.
2026-09-19 21:45:16 +02:00
Maurice 03d3f2f91e fix(docsync): refuse an orphaned binding over MCP as the REST route does
A binding whose credential owner left the trip is orphaned: the
scheduler, the webhook and the REST "Sync now" route all refuse it,
because a run would use the credential of somebody who is no longer on
the trip. The MCP sync_trip_documents tool only checked the admin
switch, so any member with MCP could still run such a binding on the
ex-member's account.

The tool now skips an orphaned binding before anything is un-shelved,
reports it with the orphaned code, and answers with an error when no
binding of the trip could run at all.
2026-09-19 21:01:45 +02:00
Maurice 80bb06c84f feat(collections): export and import a list as GPX
A list could leave the instance as a TREK file, which only another TREK
reads. Export now asks for a format, and GPX is the second one: the
places as waypoints, for OsmAnd, Organic Maps, a Garmin or gpx.studio.
Import takes a GPX in the same dialog as a list file. Asked for in
discussion #2301.

The GPX is written from the very list file the JSON export returns, so
what may leave the instance is still decided in one place. Name,
description and address, notes, website and category go into GPX's own
elements, where every app shows them. Labels, status, phone and the
rest travel in a TREK extension namespace, so a GPX made by TREK comes
back as the same list. A place without coordinates cannot be a
waypoint; it is left out and the export says how many were.

A GPX is read on the server, sent as text, with the XML library the
trip importer already uses. A document with a DOCTYPE is refused before
parsing, which closes both external entities and entity expansion. Size
is capped like the list file, and the element count is capped before a
tree is built. The reader only turns the GPX into a list file: the
browser previews it and imports it through the existing route, so one
contract, one transaction and one skipped counter cover both formats.
Waypoints and named route points become places; tracks and unnamed
route points are counted in the preview and not imported.

The two list-file routes get a body ceiling of twice the file limit.
The global 100 kB cap refused most real favourites files, and it
already refused list files well under the megabyte the contract allows.
2026-09-19 21:01:45 +02:00
Maurice bb6003684c Merge pull request #2391 from liketrek/feat/document-providers
feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
2026-09-19 19:57:16 +02:00
Maurice 154c88b48a feat(roadtrip): leave a stop at its End and show the stay that makes
A visit's End could be edited and was shown in road trip mode, but the
schedule never read it: the drive always left a stop after its stay, so
a traveller who knew when they had to leave but not how long they would
stand there had no way to say so.

The End is now the time the drive leaves. RoadtripStop.leaveAt is
filled from the visit's End (or its place's) on the client by
useRoadtripRoutes and on the server by roadtrip-plan.service, so
calculate_roadtrip and get_roadtrip_context agree with the planner, and
computeSchedule takes it as the departure it already knew how to hold.
With Start and End both set the stay is the time between them. With
daily travel times on, the stay is the time until then, and an End
before the window start starts the day early the way a Start does.
Before the first fixed time the chain works backwards through an End as
it does through a pinned Start.

An End is read against the drive: the last passing of that clock time
counts as missed when the drive went past it on the road or earlier
the same day, anything else is the next one, so a drive across
midnight neither waits a whole day nor pretends to be on time. A drive
that gets there after the End leaves on arrival and files a
missedLeave finding, shown beside the late badge in the rail and on the
phone.

The rail's STAY badge, the inspector's Stay tile, the phone stop sheet
and the phone chain show the stay the End makes, with an "until 14:00"
hint. It is read off the schedule and never written to
duration_minutes: the stay belongs to the place, the End to one visit,
and a stored copy would go stale as soon as the arrival moved. For such
a stop the stay dialog, desktop and phone, shows that stay read only
and offers to remove the End, through the visit's time route with an
offline path (assignmentRepo.setTimes, setAssignmentTimes). While the
road trip is on, the End field in both place forms says the drive
leaves at that time.

The dead checkoutAt in the plan service is gone. New strings in all 23
locales.
2026-09-19 19:24:52 +02:00
Maurice cc1c16ec34 fix(assignments): keep untimed stops in place when a start is saved
Saving a start on a stop rewrote the whole day as the timed stops in
time order followed by every untimed stop. Its own comment and the
commit that added it promised that untimed stops keep their place, but
a day of two untimed stops and a third one given 14:00 came back with
the third one first. On a road trip the stop order is the drive, so
the day was re-routed, its vias landed on other legs, and collaborators,
who only got the one edited row, saw a third order until they reloaded.

The intended rule, an untimed stop inheriting the time of the timed
stop before it, now lives once as chronoOrder in @trek/shared, used by
both the day plan (dayMerge) and AssignmentsService.updateTime. The
server still reads a booked night's hour from its check-in, and a value
without a colon still sorts after every clock time.

The day is sorted only when the stop's start actually changed, so
taking an End off or saving an unchanged start no longer snaps back a
day somebody dragged out of time order. A sorted day is numbered from 0
like a drag. In the same transaction the day's road trip vias follow
the stop they were drawn behind, the planner's own drag rule, and a
via behind a last stop that stays last is kept, so the drive into the
next day keeps its road.

PUT /assignments/:id/time and the MCP update_assignment_time tool both
broadcast assignment:reordered with the whole day, to the writer as
well, and roadtripVia:changed when vias moved, so every client ends up
with the same order.

ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the
intended one.
2026-09-19 19:24:38 +02:00
Maurice 40f8912937 fix(collections): put the cover camera beside the close button
The custom image control on a saved place's cover sat in the top left
corner, on top of the category chip, and hid half of it. It also looked
like nothing else on the cover: a square dark button next to a round
translucent close button.

The camera and the remove image button now sit in the top right corner
next to the close button, camera directly left of the X, remove further
left when there is a custom image, all three in the close button's
round translucent look with its hover, phone size and focus ring. The
phone sheet had the same overlap and gets the same row.

The category chip and the controls share one header, so neither can
run under the other: the controls hold the end and never shrink, and
the chip ellipsizes a long category instead of sliding under the
buttons in the narrow docked detail. On the phone the header is in
flow, so a place without a category starts its name below the buttons.

Picker, tooltip, labels, the spinner while an upload runs and the
removal control behave as before. The trip planner inspector keeps its
avatar overlay; it never had the overlap.
2026-09-19 19:24:37 +02:00
Maurice 66061a05df fix(docsync): sort Papra's enumeration digest with an explicit comparator
SonarCloud flags a bare .sort() on strings (S2871). The comparator keeps
the byte order the default sort used, so every cursor already stored
stays valid.
2026-09-19 15:52:26 +02:00
Maurice bcdb931a34 fix(docsync): hand a cursor on only from a run that left both sides as listed
A run stored the cursor of the listing it started from, whatever it
did afterwards. Paperless, Papra and Synology build that cursor from
what they list, so a copy TREK uploaded and somebody deleted at the
provider before the next run left the scope exactly as the stored
listing had it. The next plain run (scheduler, webhook, Sync now) read
the cursor as unchanged, planned only the TREK side, and neither
flagged the copy as missing nor asked the mass-delete guard until
something else moved in the scope or a full run came along. A copy
TREK binned and somebody restored at the provider read the same way.
WebDAV was not affected: its root ETag never returns to an earlier
value.

The same held for a run that stopped short. What the transfer budget
left over, and a download that failed, was never planned again under
an unchanged cursor, so on a quiet binding those documents did not
arrive at all.

A run now keeps the listing's cursor only when it ended ok and asked
the provider to change nothing: no upload, no rename, no trash. Any
other run stores none, and the next one compares both sides in full
and hands a cursor on once it ends clean. On Paperless, Papra and
Synology that costs nothing, since they walk the scope on every run
anyway; on WebDAV it is one Depth:1 listing instead of the Depth:0
probe.
2026-09-19 15:13:02 +02:00
Maurice ec2711cec0 fix(docsync): name the store of a binding whose provider is switched off
Both shells keep a binding on screen after an admin switches its
provider off, and name its store through storeName(), which falls back
to a providerName on the link before the raw id. The server never sent
one, so such a binding was labelled with the provider id. publicLink
now carries providerName from document_providers.name whatever its
enabled flag, and every route and the MCP listing that return links go
through it.

The comments on docsync:changed in the realtime schema and the client's
event policy say that the event now also marks a binding created or
removed, and that the Files screen re-asks whether to offer the sync
button when it arrives.
2026-09-19 15:13:02 +02:00
Maurice 233f46575c fix(docsync): make the admin switch pause a binding, and retry a failed update
The check that was meant to stand a binding down when its provider is
switched off asked AddonsService.isAddonEnabled(link.provider_id). The
addons table has no rows for paperless, papra and the other document
providers, so it answered "off" for every binding, and every run came
back disabled before it listed anything. The unit test stubbed that
answer and the e2e never runs a sync, so nothing caught it.

isSwitchedOff() now asks the two things an admin actually switches: the
Documents addon, and document_providers.enabled for the binding's
provider. dueLinks leaves such bindings out in SQL, since one that
never runs never gets a last_sync_at and twenty of them would take
every slot of a tick. A run, the webhook on arrival and when its timer
fires, "Sync now" and the MCP sync tool all refuse before anything is
written, so a paused binding keeps its state and its documents' attempt
counters and resumes where it stopped. The REST route answers 409
provider_disabled, the MCP tool reports the same code, status() carries
providerOff per link, and both shells show one line saying the binding
is paused because its provider is switched off, in all 23 languages.

A failed pull_update also left the row stuck. upsertItem wrote the
listing's version, size and time next to the old bytes, so the next run
saw no upstream change, planned touch, which leaves error alone, and
the document stayed in error on stale bytes for good, "Sync now"
included. A failed transfer now leaves the pairing describing the copy
TREK holds; a new row keeps what pairs it but no version, so it is
fetched again.
2026-09-19 15:12:47 +02:00
Maurice 9de8490176 style(docsync): drop dash punctuation from the added comments and test titles
The lines this branch adds used em and en dashes as sentence
punctuation across comments, JSDoc, CSS comments and test titles. Each
one is rewritten with a comma, a colon, parentheses or a second
sentence, keeping what the sentence said.

Only lines this branch adds are touched; everything that already
exists on dev stays as it is. No logic, error code or logged message
changes. The only string literals affected are describe and it titles,
which now read "X: topic" like the rest of the provider tests.
2026-09-19 13:36:51 +02:00
Maurice 843582863c fix(docsync): offer the sync button only where something is behind it
The Documents addon put a "Document sync" button on every trip's Files
tab, and the seed ships every provider switched off, so after the
upgrade each of those buttons led into "No document providers are
available". The button now shows on a trip that is already bound, to
every member, and otherwise only to somebody who may bind the trip on
an instance that offers a provider. Without it the Files toolbar is
the one from before the feature, trash flush right, on both shells.

/api/addons carries no document providers for a normal user, so the
answer comes from the docsync routes through a new online-only
docSyncRepo and is kept per session in docSyncOfferStore. A revisit
renders the last answer at once and checks it again; offline nothing
is asked and the last answer stands. Creating a binding, removing one
and removing a connection now ping the trip with docsync:changed and
zero counts, so a member already on the Files tab sees the button come
and go without leaving it.

Both shells keep listing a binding whose provider the admin has since
switched off instead of claiming there are no providers, and name its
store through one storeName() helper. A load that answers 403 or 404
clears the bindings as well, so they do not linger once the Documents
addon is gone.

The phone admin screen gets the Documents shelf the desktop one has:
the document providers as sub rows under the addon, toggled through the
same admin route and re-read after Documents itself is toggled, because
the server switches the providers off with it.

isTripOwner and the isOwner prop become canManageSync and canManage,
which is what canManageDocSync answers. Both folder pickers take
loadScopes out of the sync object before their effect, so
exhaustive-deps is satisfied without the effect running on every
render.
2026-09-19 13:36:33 +02:00
Maurice 25d4e34959 fix(docsync): keep the Synology device token across a restart, tied to its row
The DSM trusted-device token lived in process memory only. After a
restart every token was gone and the stored one-time code long spent,
so every two-factor NAS binding stopped syncing until its owner typed a
fresh code, and nothing on screen said why.

The token is now kept in the connection's encrypted secrets, together
with a digest of the instance and account it was issued for. A saved
connection's ref carries a saveSecret callback that writes through
DocSyncConfigService.saveEarnedSecret, which rereads the row inside a
transaction so a sync run holding an old ref cannot undo a password
the owner changed meanwhile. The key belongs to no form field, so
maskSecrets never reports it and mergeSecrets never touches it: the GET
and PUT payloads do not carry it, an edit keeps it, and a client cannot
plant one. It survives a password edit and a spent or cleared code. A
connection repointed at another NAS or account drops it on its next
call, and a token DSM refuses with 403 or 404 is dropped in memory and
in storage. A probe of form values and an unsaved form may use what is
stored but never write. A refused token no longer shadows a fresh code:
the code in the form gets its turn in the same login call.

In memory the slot is keyed by the connection id together with the
row's created_at. A backup restored in place or the demo reset rolls
the id sequence back without a restart, and a new connection that
reused an id must not log in on the token of the row it replaced.

The Synology adapter receives its client through DI like Paperless and
WebDAV, instead of building a second instance beside the one the module
registers.

Two readings of a failed request are settled as well:
- INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an
  untrusted certificate again, so the settings screen offers the
  self-signed switch. Papra used to find them through the message text,
  and the shared classifier reads codes only.
- A host that never completes the TCP handshake reads as unreachable on
  every client. WebDAV said timeout before the shared transport; the
  change is now documented and tested.
2026-09-19 13:36:16 +02:00
Maurice 71400b7e85 fix(docsync): follow a rename on Synology, and settle restores and purges in one run
Where the id is the path, a rename or a move upstream lists the same
copy under a new id. The upstream-only loop re-paired it with a touch,
which went wrong twice. The pairing had already been counted as
vanished, so the same run flagged it missing as well and counted it
towards the mass-delete guard. And touch wrote the provider's new name
into remote_name while the trip file kept the old one, so the next run
read that as a rename made in TREK and renamed the provider's copy
back. The heuristic also matched on a content hash only, which Synology
never sends: on the one provider without stable ids it did not fire at
all, and every rename there ended as a missing flag plus a second
download of the same bytes.

The planner now moves such a pairing first, with a relocate action that
updates remote_id and the version and leaves the agreed name alone. The
pair loop then treats it like any listed document, so the new name
comes into TREK through rename_local exactly as it does where ids are
stable, and nothing counts as vanished. A copy is recognised by its
content hash where the listing has one, otherwise by size and
modification time, which a rename or a move leaves untouched. Only a
one-to-one match is followed, and a copy TREK binned itself never ties
with a live twin of the same bytes. The rows now load remote_size and
remote_modified_at, which were written on pull and never read; touch
keeps them current and a push records them. rename_local and the
provider branch of resolveConflict take the provider name in its
cleaned form, since a raw name with a colon or a slash read as a rename
made in TREK on the next run.

Restores and purges:
- A file taken back out of TREK's trash whose copy somebody else had
  deleted meanwhile resumed as synced and was then counted as vanished,
  so a few such restores tripped the mass-delete guard on every run
  until the circuit opened. It now lands as remote_missing directly,
  outside the guard, and the service asks for a full listing while such
  a restore is pending.
- A row in error that once held a file now reads as a deletion once the
  file is purged, told apart from a first download that never landed by
  the agreed hash. "Sync now" used to download the deleted document
  again.
- Moves are followed before a restore is judged, so a binned copy
  restored at the provider under a new path resumes its pairing instead
  of being duplicated on both sides.
- relocate and both renames lift a remote_missing flag the way touch
  does.
- A double rename under trek_wins or provider_wins is settled with a
  rename instead of a transfer.
2026-09-19 13:35:55 +02:00
Maurice 98e2938033 fix(maps): let an uploaded place image fill its marker
A place with an uploaded image showed a plain disc of its category
colour on the map, while a picked suggestion looked fine. The marker
sized its img with width and height attributes, and stylesheet rules
beat those: Tailwind's preflight gives every img height: auto, and
Leaflet's marker pane adds width: auto. Suggested and auto photos reach
the marker as 48px square thumbs, so nothing showed. A phone photo kept
its own proportions on the GL map, a sliver inside the circle, and its
full pixel size on Leaflet, where only the rounded-off corner of a
4000px image lands inside the circle. Both maps now build the img
through one helper that sizes it in its style, so object-fit: cover
crops any photo to the circle.

The thumb loading on both maps was keyed on place ids, so a change of
picture under the same place went unnoticed until a reload:
- Taking an upload off a place left the category dot instead of
  bringing its auto photo back.
- Picking another suggested photo kept the thumb of the first one,
  which was cached under the provider id. A picked photo is now its own
  cache key.
2026-09-19 13:35:44 +02:00
Maurice b88b92add7 fix(docsync): a paused binding no longer resets its own settings
PATCH links validated its body with a schema derived from the create
contract through .partial(). In Zod 4 a .default() still fires inside an
optional field, so a patch naming one setting arrived with every other
one filled in: pausing a pull-only binding switched it to two-way, reset
both policies and blanked its label. The update contract is now written
out without defaults, and an e2e test drives a patch through the real
pipe, which the controller unit tests never see.

Also, the cosmetic leftovers from the review:
- POST connections/:connectionId/scopes no longer wants the connection
  in the body as well. It was required, never read, and let one request
  name two connections; a client that still sends it has the key
  stripped rather than refused.
- createLink loses the scope argument every caller passed as null.
- The controller's owner check is now assertCanManage, because it lets
  instance admins through just as canManageDocSync does on the client.
  Who may manage a binding is unchanged.
- document_provider_fields drops settings_key and payload_key. Nothing
  read them: the connect form takes its fields from /docsync/providers
  and the admin shelf sends none for document providers.
- The e2e test imports the provider classes under their new names.
2026-09-19 12:07:28 +02:00
Maurice 01a7224f29 fix(docsync): key the Synology device token per connection and share one transport
The DSM trusted-device token was cached per NAS and account. A second trip
whose owner knew the account password but never had the authenticator could
bind the same account and log in on the first trip's token, a two-factor
bypass across trip owners. Nothing ever cleared the entry either, so a token
DSM stopped trusting went out ahead of any fresh code on every attempt until
the process restarted.

The token is now filed per connection, with instance and account still in
the key. An edit keeps the connection id, so the token survives a password
change and a stale code in the form. An unsaved form (connection 0) keeps
what its probe earns on the session only, and the saved connection adopts
it from there. A token DSM refuses with 403 or 404 is dropped for that
connection alone.

The four clients each carried their own copy of the transport, and the
copies had drifted. provider-http.ts now holds the SSRF-guarded fetch with
its timeout and certificate switch, the reading of a request that got no
answer, the base status table and the guard on a streamed download.
Timeouts, caps, auth headers and each client's error class and wording are
unchanged. Where the copies differed by accident they now agree:

- Paperless recognises UNABLE_TO_GET_ISSUER_CERT_LOCALLY (a private CA) as
  tls_untrusted instead of unreachable.
- ERR_SSL_* handshake failures read as unreachable on WebDAV too, since the
  self-signed switch cannot fix them.
- Synology maps HTTP 409, 415 and 507 like the others, Paperless maps 415.
- Every download is refused on an announced length over its ceiling and
  counted as it streams. WebDAV and Paperless had no ceiling, Synology only
  read the header, and Papra's limiter left the source pinned when it
  tripped.
- Papra reported a download without Content-Length as zero bytes, so the
  size from the listing was never used.

PapraProvider and SynologyDriveProvider are renamed PapraDocumentProvider
and SynologyDriveDocumentProvider, matching the other adapters.
2026-09-19 12:07:28 +02:00
Maurice dd3d97589b fix(docsync): act on a deletion once, and know what to do when it is undone
Both planner branches planned local_deleted for as long as a file sat in
TREK's trash. Under unlink nothing showed, until the binding was switched
to trash: the next run then binned every document ever deleted in TREK,
months back included. A binding on trash from the start asked the provider
to bin the same copy on every run. A local_deleted row is now left alone,
so the policy is applied once, under the policy in force at that moment.

That guard also meant a file taken back out of TREK's trash stayed on
local_deleted for good, which it already did before: no transfer, no
upload, no flag, and a second deletion of the same file would never have
propagated. The row now leaves that state by what became of the provider
copy:

- still there: the pairing counts again and the row is judged like any
  synced one, so an upstream edit made meanwhile comes down;
- binned by TREK: the pairing is dropped and the file goes up again as a
  new document through the ordinary push, keeping its trek_doc_uid;
- deleted by somebody else: flagged remote_missing, never uploaded over
  their deletion.

Telling the last two apart needs a record of what TREK did, so the new
remote_trashed_at column (migration 239) is written when TREK bins a copy
and cleared when the pairing is back, or when the binned copy is listed
again because somebody restored it at the provider.

A file purged from the trash came straight back as well: file_id is ON
DELETE SET NULL, and the row read as a download that never landed. A
tombstone now stays a tombstone, and a purge no run had seen yet is
treated as the deletion it is.

Also drops snapshotHash, which nothing read.
2026-09-19 12:07:28 +02:00
Maurice dc1ac0ae85 fix(docsync): give the sync flow its own TREK icon and restore the wordmark
The document-sync flow needed TREK's square app icon next to the
provider glyphs, and the PR got it by rewriting the shared TrekMark
component. TrekMark is the wordmark with the API pill that the admin
Places API card, the mobile admin block and the release notes popup
draw, so all three showed the app icon instead, and
AdminSettingsTab FE-ADMSET-031 failed on the missing "TREK Places API"
image.

TrekMark is back to its dev version. The app icon now lives in
TrekIcon, which DocSyncFlow and MDocSyncSheet use.

AddonManager had also switched Synology Photos to the Synology Drive
glyph, which renders as a block and sat off-line from the Immich row
under Journey. Photos keeps its own icon again. The new Documents
shelf gets tests: its rows and glyphs, staying out of the addon count,
hiding while Documents is off, the re-read after Documents is switched
off, and a provider toggle that saves without re-reading.
2026-09-19 12:04:18 +02:00
jubnl ac47066a93 change oidc behavior with remember me on by default when oidc only enabled (#2397) 2026-09-19 09:12:15 +02:00
Maurice 9f531d1cd9 feat(docsync): make the conflict answer real and give a conflict a way out
The binding asked at setup what should happen when both sides changed, stored
the answer, and nothing ever read it: every conflict was manual whatever the
binding said. The planner now acts on it — provider_wins pulls, trek_wins
pushes, anything else parks the row — and never in the direction the binding
forbids.

Conflicts themselves had no exit in either shell. The server has resolved them
since the first version and the strings have been in the locales just as long,
but neither the panel nor the phone sheet ever listed the rows, so the only way
to answer one was the MCP tool. Both shells now list them through one shared
hook with the same three answers.

Also:
- The desktop connect dialog shows a refused save instead of looking as though
  nothing happened.
- An error the server phrases as a sentence no longer reaches the screen as a
  raw translation key.
- The scope pickers depend on the stable callback rather than the hook object,
  so opening one no longer re-lists the provider's folders on every render
  above it.
2026-09-19 00:57:57 +02:00
Maurice e6a5ca1521 fix(docsync): stop a push from stealing another file's pairing
Papra answers a push of bytes it already holds with the document that has
them. Two trip files with the same content therefore pushed to one document,
and the unique index on (link_id, remote_id) aborted that run and every run
after it with a constraint error. The pairing now stays with the file that
owns it and the second file is recorded as a conflict.

Also in this pass:
- Paperless no longer adopts a same-bytes document that lacks the scope's tag
  when a consume outlives the poll budget; that document belongs to someone
  else's filing and delete-through would have trashed it.
- The pull path writes the file row, the retirement of the copy it replaces
  and the pairing in one transaction, and announces them only once committed.
- PATCH links stops accepting remoteRootId/remoteRootPath, which it read and
  silently dropped.
- The MCP sync tool clears shelved items before running, as the REST route
  does.
2026-09-19 00:44:43 +02:00
Maurice 7237a1881d fix(docsync): switches that did not switch off, a webhook nobody could authenticate, and a counter that only grew
More from the audit, each checked against the code before it was touched.

The webhook obeyed neither the Documents addon nor the app_settings kill
switch, so "switch document sync off" stopped the poll while every provider
holding a webhook carried on driving full runs. Both are now read on arrival
and again when the debounce timer fires, so a switch thrown mid-window still
takes effect.

Nextcloud's subscription registered its secret under `X-TREK-Docsync-Signature`
while the endpoint reads `x-trek-docsync-secret`: the subscription existed, the
calls arrived, and every one of them was dropped for carrying no secret the
controller could find. The two names now agree, and the test pins the one the
endpoint actually reads.

A WebDAV create was an unconditional PUT, so a name already taken upstream was
silently overwritten — two trips bound to one folder, or a `receipt.pdf` a
person put there by hand, is enough. Creates now send `If-None-Match: *`.

The per-item retry bookkeeping was stuck at both ends: the backoff was computed
at step 1 every time, so three of the curve's four steps were unreachable, and
the attempt counter only ever grew, which shelved a row that failed once a
month after six months of otherwise healthy syncing. A successful pass now
clears it, which is what makes the limit mean six failures in a row.

Also: a manual run on an orphaned binding is refused with 409 (its credential
belongs to somebody no longer on the trip, and the scheduler already refuses
it, so the button was a way around that); a document deleted while a run is in
flight is no longer uploaded anyway; a path-shaped WebDAV id containing `..` is
rejected instead of addressing a file outside the bound folder; and Papra's
multipart part header no longer takes an unescaped media type, which a CR could
end early.

Client: three writes in useDocSync swallowed their failure into `void` call
sites, a load failure blanked the panel to "no providers available" whatever
the cause, and overlapping loads let the slowest response win. Writes now
report, only 403/404 blanks the panel, and a generation stamp keeps the newest
answer. The dead lastRun state went with them.
2026-09-19 00:31:13 +02:00
Maurice 4fa06c3483 fix(docsync): an idle WebDAV binding read as an emptied folder, and two ids nobody checked
The audit's worst find, reproduced against a live Nextcloud before and after the
fix: the WebDAV adapters answer "nothing changed here" by returning an EMPTY
listing plus `cursorUnchanged`, and nothing read that flag — `grep -rn
cursorUnchanged src/` found it set by four adapters and consulted by none. The
core therefore saw a folder that had lost everything. Run 1 and 2 look fine
because the run's own uploads move the ETag; from run 3 on, every run of an
idle binding ends `partial` with `mass_delete_guard` and six documents reported
missing, forever, until failure_count opens the circuit and the scheduler drops
the binding entirely. That is the ordinary case — a binding nobody is touching.
planReconcile now takes `remoteUnchanged` and plans only what happened in TREK.

Two ids were authorised by trip and never checked against it:

  - resolveConflict took an item id on trust, so the owner of any trip could
    decide a conflict belonging to somebody else's. Both are plain integers and
    nothing compared them.
  - The connection test merged the STORED credential into a probe of whatever
    baseUrl the caller named — a way to have TREK post another account's API
    token at a server of the caller's choosing. Stored secrets are now reused
    only for the address they were stored against.

The phone's connect form was wrong in three ways at once, each of which the
desktop had right: it read the address as `baseUrl` while the field is
`base_url` (so every connection it saved carried an empty address), it drew
`allow_insecure_tls` as a text box and always sent false, and it built label
keys as `docsync.provider${cap(label)}` when the label column already holds the
full suffix — `docsync.providerProviderApiKey`, rendered raw on screen. Those
rules are not markup, so they now live in `useConnectForm`, which both shells
use. Its test fixture invented the field shape and so agreed with the broken
sheet; it now mirrors document_provider_fields.

Also: the scheduler read its interval once at bootstrap while this file's own
comment promised a per-tick read, so changing it did nothing until a restart;
and dueLinks ordered by a column that a successful run clears, which left every
binding tied and SQLite answering in insertion order — on a database with 170
bindings the same twenty ran on every tick and nothing created after them was
ever synced automatically.
2026-09-19 00:07:33 +02:00
Maurice 1430056d24 fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI
The attempt counter and the backoff were written on every failure and never
consulted when planning the next run: rows were found in a live database at 46
attempts against a limit of 6, so a document a provider refuses — one it reads
as corrupt, a type it will not take, a full quota — was re-uploaded on every
cron tick forever. planReconcile now skips a row that is shelved or still inside
its window, and a manual run clears the counters, which is what "waits for a
person" has to mean.

The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed,
with a comment explaining it, and nothing read it. Providers fire once per
document, so twenty files dropped into a watched folder were twenty runs — the
in-flight guard answered `busy` to nineteen of them, throwing away nineteen
announcements, and the one run that did start had begun before most of the
changes landed. A burst now folds into one trailing run, and the binding is
re-read when the timer fires so a sync switched off in between does not get a
last run out of a stale row.

Four defects the new suites turned up, all fixed: the sync button lost its
accessible name below sm, where its label is hidden; the stored address was
never shown in the connect dialog although the form kept submitting it; the
allow-self-signed switch only read the connection at mount, so a stored "on"
showed as off and the next save wrote that back; and a refused folder create
escaped as an unhandled rejection with nothing said to anybody.

Also here: the phone shell gets the same feature (one sheet, four views, the
same hook as the desktop), the marks come from the product's own icon files
rather than redrawn approximations, and the 22 non-English locales carry the
new keys.

ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the
test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
2026-09-18 22:44:45 +02:00
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
2026-09-18 19:23:07 +02:00
Maurice 902dbbf84c Merge pull request #2390 from liketrek/fix/roadtrip-day-routes
fix(roadtrip): a stop on a dead end took the whole day's routing with it
2026-09-18 14:58:16 +02:00
Maurice d4c6ac9fe8 fix(costs): the settlement fixture carries the rate its type never had
A foreign-currency payment gained an exchange_rate in the ledger fixture without
the field on the local Payment type beside it, and the client typecheck has been
red since.
2026-09-18 13:18:04 +02:00
Maurice 5ef8bf31f4 fix(roadtrip): a stop added by hand opens on the kind being looked for
The button carried nothing and the form hard-wired the kind, so the categories
sitting directly above it had no way through and every manual stop started life
as a fuel stop with ten minutes on it. The click now carries what the corridor
is looking for, dwell included. The picker is multi-select, so the first kind
the panel lists wins rather than a fallback nobody switched on; with nothing
selected the form falls back on its own, because a stop without a kind is a
numbered destination that counts in every total.

This also closes the case it was noticed on: an electric car seeds the panel to
charging, so the picker said Charging over a form that said Fuel.
2026-09-18 13:17:59 +02:00
Maurice e9742e9322 fix(roadtrip): a refused run asks again one pair at a time
One request per day is thrifty, and the price of it is that a refusal is never
local. With the usual cause gone the shape of the failure is still there, so a
run the router refuses outright now asks for its legs one at a time instead of
leaving the day blank. The pairs are enqueued rather than awaited, so they keep
the spacing of every other request, and a rate limit is never split: answering a
host that asked for less traffic with four more requests is the opposite of
backing off. A refusal the host meant skips the two retries, because the same
coordinates earn the same 400.

Seam legs are merged under the day's own runs now rather than over them.
legsByDay is replaced whole on every routing round while seamLegs only ever
grows, so a pair that used to sit on a day boundary and now sits inside one day
kept an answer nobody re-asks for, shaped by the vias of a stop that has moved.
2026-09-18 13:17:51 +02:00
Maurice 371b7da9de fix(roadtrip): let a drive turn round at a stop it passes through
A day is routed as one request over all of its stops, and OSRM's car profile
forbids a u-turn at an intermediate waypoint unless asked. When it cannot obey
it refuses the whole request rather than the one leg, so a single stop on a dead
end took every leg of the day with it. Gouffre de Padirac snaps onto a cave
access road that goes nowhere else: each of that day's pairs routes fine alone,
all five together come back 400 NoRoute, and the same five with
continue_straight=false come back with their legs.

The permission goes on every request that has a stop in the middle, in the
browser and in the server's planning path, which had the same defect. Two-point
requests keep the url they had.

OSRM refuses an unknown query parameter outright rather than ignoring it, so a
router that has never heard of this one is recognised by its InvalidQuery,
asked again without it, and remembered per origin.
2026-09-18 13:17:43 +02:00
Maurice 7f87dd512d fix(costs): read a foreign-currency expense at the rate it was booked at
The server freezes the FX rate when an expense or a settle-up transfer is
entered and settles the balances with it, but the client ignored the column
and converted every amount with today's rate. A bill paid in dollars was
therefore worth something different each time it was opened, which a tester
noticed after settling up: the figure moved under him, and the row no longer
matched what had actually changed hands.

convertBooked applies the server's own rule, in one place for both shells and
the transfer rows: an amount already in the trip currency is left to the live
step, a frozen rate converts to the trip currency before the live step, and a
rate of 1 or none at all is a row written before the freeze existed and keeps
converting live.
2026-09-17 22:04:01 +02:00
Maurice 0e2d255d81 test(roadtrip): find the phone's navigate link by its state, not its label
The Up next card stopped printing a fixed key when the audit replaced the
nonexistent places.navigate with the single navigation target's own name, so
the assertion went looking for text that no longer exists and took the 4.3.0
branch red. The link is found by aria-disabled now, which is what the test is
actually about.
2026-09-17 22:03:52 +02:00
Maurice 543a052da7 Merge pull request #2387 from liketrek/fix/pr2273-audit-0917
fix: eleven from a full audit of the 4.3.0 release branch
2026-09-17 16:55:23 +02:00
Maurice 7806560c8f fix(roadtrip): four more from the audit, each one traced to what the user would see
**"Today" was read off UTC while the clock beside it is local.** `localDate.ts` exists for
exactly this and says so in its own comment: between local midnight and the UTC rollover the
two disagree, so east of Greenwich the up-next card hung on yesterday's stage for the first
hours of every night, which is when somebody on a long stage is actually driving. Both the
stage hook and the corridor hook now ask the same helper the rest of the app asks.

**A refused day end left the switch showing a state the trip never reached.** The writer
catches its own failures and shows a toast, so it never throws and the sheet's catch could
not fire, which left the optimistic value standing. It answers whether the day end moved
now, and the switch comes back when it did not. The desktop control ignores that answer, as
it reads its state back from the trip.

**A timed-out corridor search reported more unsearched boxes than it had.** Every worker
still alive ran the deadline branch and each added the whole remainder, so three workers
reported three times the boxes left, which can exceed the number of boxes there ever were.
The remainder is claimed before it is counted, which also ends the other workers' loops.

**A night that fell mid-leg had its morning drive dropped from the stage map.** The join
mark went on the first leg of every day opening on an automatic night, but only a night
standing ON a stop is a connection between two days. `dayWindow` records that difference as
a whole `position` against a fractional one, and where it is fractional the morning drive is
the last stretch to this day's own first stop. It was being filtered off the stage while its
distance stayed in the day's total.
2026-09-17 16:49:03 +02:00
Maurice c8f9edad87 fix(i18n): the corridor's truncation note takes a singular, and three plurals were wrong anyway
`mobileTrip.rtTruncated` interpolated a count into a sentence naming the stretch once, so at
one truncated area it read "1 stretches". Two keys now, picked by the count, the shape this
codebase already uses where a number changes its noun.

Writing the singular turned up three plurals that were wrong at counts the old one-form key
could never reach. Polish and Czech both had the genitive plural with a singular verb, which
is right at five and wrong at two; Arabic had the 3 to 10 form, wrong at two and again from
eleven. All three now put the count after a colon, where no form has to agree with it.
Russian and Ukrainian were already correct at both, since their phrase leads with a
locative, and were left alone.

The eight languages that keep the singular noun after a numeral differ only in the numeral,
which is what they should do.
2026-09-17 16:40:05 +02:00
Maurice 92ccd24eed fix(roadtrip, journey): six the audit turned up, each one verified before it was touched
**A saved stay left an invisible sheet standing, and the day swipe stayed dead behind it.**
The stay sheet reopened 'rtstop' by spreading its own payload, which carries a place id.
That sheet is located by day and assignment, so it resolved to nothing and drew nothing,
while `shell.sheet` still pointed at it and `menuOpen: !!shell.sheet` kept the day swipe
blocked until something else closed it. The row rides along now, both ways, and with no row
to go back to the sheet closes rather than opening one that resolves to nothing.

**"End the day here" showed off for a day that already ends there, and switching it on
deleted that day end.** Two things close a day: the stop's own flag and a manual boundary
filed against it. The writer has always known about both; the phone switch read only the
flag, so the tap ran through the writer's boundary branch and cleared the boundary. The
question is asked once now, in the planner beside that writer, and the desktop reads the
same answer it always computed inline.

**The road trip chain printed 24-hour clocks at a reader on a twelve-hour one.** The model
hands clocks over as HH:MM, which is what the schedule computes in, and the chain, the head
card and the up-next card printed it straight. The desktop rail, the day timeline and this
tab's own sheets all honour the setting; these go through the same formatter, carried in the
row chrome so no two rows can disagree.

**The up-next card's navigate button read "places.navigate".** No locale has that key. It is
`inspector.navigation`, which is what the stop sheet beside it, the place inspector and the
place sheet all use, with the single-target label when there is only one.

**Offered roads were invisible on a satellite basemap.** The imagery is anchored under the
first layer TREK draws, found by prefix, and the roads offered for a leg are added before
the route source so they sit under the current route. That also puts them before the first
`trip-` layer, so the raster went straight over them. The prefix list knows them now.

**A journal grew a day section for every day Dawarich recorded, all year.** The hook reads
every open stay the account has, and the timeline took all of their dates. A week in May
therefore sprouted sections from September. Bounded by the journal's own span now, its
entries together with the dates of the trips it links, and with no span at all it offers
nothing rather than everything.
2026-09-17 16:38:17 +02:00
Maurice 08df1812be fix(sonar): spell out the order these five sorts already have
SonarCloud's S2871 fires on a bare .sort() over strings and asks for localeCompare, and it
had five of them on new code, which is what put new reliability at D and failed the gate.

All five sort ISO dates or query parameter names, where the default lexicographic order is
exactly the one wanted. Taking the rule's suggestion literally would have been the wrong
fix twice over: a locale-aware comparison reorders dates differently per runtime, and on
the Amap signature it would produce a canonical string Amap itself does not compute, so
every signed request would come back rejected.

So the comparator is written out rather than changed. Byte order, explicitly, with a line
saying why it has to stay byte order. Behaviour is identical: the 43 Amap cases, the 40
track cases and the 30 client cases over these modules all pass unchanged.
2026-09-17 16:19:02 +02:00
Maurice ce5cc49b58 Merge pull request #2384 from liketrek/feat/roadtrip-mobile
feat(roadtrip): bring the road trip addon to the phone
2026-09-17 15:33:47 +02:00
Maurice 8860e16149 test(journey): cover the Dawarich fold, and mend what the switches and the wiring assumed
Three things the CI would have caught.

The settings dialog's switches are the shared ToggleSwitch now, which is a button with
aria-pressed rather than a role="switch" with aria-checked, and three tests still looked
for the old shape.

The page's wiring test mocks the whole hook, so the day map the timeline reads was
undefined there and every case in the file died on it. The mock carries it now, empty,
because the fold has a suite of its own.

That suite is new: what it pins is the fold rather than the rows inside it, which the
panel's own tests already cover end to end. That it says nothing on a quiet day, that the
line reads shut, that the count takes the singular at one, that it starts closed, that
both actions reach the journal with the stay they belong to, and that the span follows
the traveller's own clock.

The grouping the hook does moved into the model beside the other pure shaping, where it
is four cheap tests instead of a round trip through MSW: by day, in the order they were
lived, only what is still waiting, and no bucket at all for a day whose stays are all
handled.
2026-09-17 15:32:19 +02:00
Maurice 185f6c5335 Merge pull request #2373 from liketrek/fix/journey-skeleton-per-day
fix(journey): a place kept across two days is two days of the journal
2026-09-17 15:31:37 +02:00
Maurice 1e338e2c71 Merge pull request #2383 from liketrek/fix/mobile-markdown-place-description
fix(places): render markdown in the mobile place description
2026-09-17 15:31:13 +02:00
Maurice 2879433bd3 fix(dawarich): the panel opens to its full height and the rail scrolls past it
Capping it was the wrong half of the fix: the stays were reachable but the panel was cut
off at half the rail, which is not what opening something means.

It sits inside the places list's own scroller now, above the rows, so it simply grows to
whatever the day's stays need — 3350px for forty of them — and the rail scrolls past it
to the places. No second scrollbar, no fold. As a band of its own it could never do that:
the list is the flex child that scrolls, so anything unbounded above it squeezed the list
to nothing and took the rail's scrolling with it.
2026-09-17 15:05:29 +02:00
Maurice 1908d6f5f9 fix(dawarich): the places rail scrolls again with the panel open
The panel sat in a wrapper that could not shrink and did not scroll, so opening it took
whatever height the day's stays needed — forty of them is over 3000px — and squeezed the
places list, which is the flex child that scrolls, down to nothing. The rail stopped
scrolling and most of the stays sat below the bottom edge with no way to reach them.

The wrapper is capped at half the rail and scrolls on its own. Open, the stays are all
reachable and the list underneath is still a list; shut, nothing changes, because the cap
only bites once the content passes it.
2026-09-17 15:02:58 +02:00
Maurice 425e1ef183 fix(dawarich): the trip rail drops the duration badge
A stay in the trip rail showed both when it happened and how long it lasted. The rail is
narrow and the row does not wrap, and of the two it is the arrival that places a stop; how
long somebody stood there is a second figure competing for the same line.

Off wherever the panel is scoped to a trip, which is the planner rail and the phone's
places browser, and the same test `showTrip` already uses. The journal keeps it: there a
stay becomes an entry, and how long it lasted is part of what the entry says.
2026-09-17 14:58:30 +02:00
Maurice 9f57bbf4fb fix(dawarich): the stay times follow the clock the traveller set
Every time Dawarich shows was cut straight out of the timestamp and printed, so on a
12-hour setting the app said "2:05 PM" everywhere and "14:05" here. Both ends of a range
go through the same formatter the rest of the app uses now, and the argument is
required rather than optional so the next caller answers the question instead of
inheriting the default.

That covers the rows in the panel, on the phone and in the planner, and the summary line
a journal day carries, which reads its span through the very same function so the fold
and the stays under it cannot disagree about the clock.

The accept dialog was already right: it fills a CustomTimePicker, which knows the
setting, from the 24-hour value it keeps internally.
2026-09-17 14:53:58 +02:00
Maurice 3dd3e1e8d2 fix(journey): the day count stays the journal's own
A day a pending stay was waiting on now joins the timeline so the stay can be reached,
and the header counted those days as part of the journey: a journal of seven days read
"11 days" because Dawarich had recorded on four more. The figure comes off the entries
again, which is what the word means.
2026-09-17 14:47:23 +02:00
Maurice bbcf17d816 feat(journey): the day carries its own Dawarich stays, folded
The stays Dawarich recorded used to be a panel above the timeline: every open stay of
every day, stacked over the entries. A fortnight of driving puts forty of them between
the reader and their own first entry, which is the scrolling Roel described on the
16th. They belong where the day is.

Each day of the timeline now ends in one line: the Dawarich mark, how many stays, and
the span they cover. A tap opens them, and what opens is the panel's own rows, imported
rather than copied — one row this detailed in two places would be two things to keep in
step and a straight hit on the duplication budget. Nothing is drawn on a day with
nothing pending.

A stay falls on the day it happened, which is often a day the journal has no entry on
yet, and that is the whole point of offering it. Those days join the timeline so their
stays can be reached at all; without that the only way to a quiet day's stays would be
to write an entry on it first. Not while a search is running, where a day whose entries
the query filtered out is not a day the reader is looking at.

The count takes two keys picked by the number, the shape this codebase already uses for
a noun that changes with it: there is no plural engine in the i18n layer, and one key
with a placeholder in it reads as "1 stays". Russian, Polish, Czech, Ukrainian and
Arabic inflect again at 2-4 and 5+, which two forms cannot express, so their plural puts
the number after a colon, where no form has to agree with it.

The phone is untouched: it opens the same list in a sheet from its own button, which is
where it works on a screen that shows one entry at a time.
2026-09-17 14:30:14 +02:00
Maurice d18077d171 fix(journey): the Dawarich button clears the card rail, and the settings switches read in the dark
Two on the journal.

The phone's Dawarich button floated a hard-coded 128px over the dock, which is short of
the rail underneath it: a day scrubber over a row of covers, and a cover grows from 152
to 180px when it becomes the active one. The button sat on the cards. It now clears the
rail's measured height, observed rather than read once, because that height changes with
every swipe. No rail, as on an empty journal, means no offset.

The settings dialog drew its four switches by hand in emerald and zinc, with a white
knob. In dark mode that is white on near-white: the screenshot is three pills where
neither the state nor the knob can be made out. They are the shared `ToggleSwitch` now,
which is the component every other switch in the app uses and which solved this exact
problem with `--accent-text` on the knob. The row is no longer one big button, since a
button cannot hold another: text left, switch right, the layout the settings screens use.
2026-09-17 14:10:52 +02:00
Maurice 84923b97a7 fix(roadtrip): the connection built by a day window is a connection too
The last change marked the drive into the next day and kept it off a phone stage, but it
only caught the connection `connectDays` builds through `inboundAt`. A trip with a day
window set never takes that path: the window switches `inboundAt` off and builds the days
itself, so day 2 opens on an automatic night standing where day 1 stopped and its first
leg is the drive on from there. That leg is the same connection, it just arrives as one
of day 2's own legs under day 2's number, and the stage drew the whole way back to
yesterday's last stop on today's map. The setting is not even involved: with a window it
happens either way.

So the leg out of an opening night is marked as a join as well, and the phone drops it
with the rest.

One exception, because the filter must not empty the screen: a day that is nothing but
driving, night to night with no stop of its own, has only the connection to show, and
there the line IS the day. When dropping the joins would leave a stage with no line at
all, it keeps them.
2026-09-17 13:55:56 +02:00
Maurice c0ff4dabb3 fix(roadtrip): a phone stage no longer draws the drive into the next day
"Connect the days" is a whole-drive setting: with it on, the leg from one day's last
stop to the next day's first is drawn in the colour of the day it leaves, so the desktop
map reads as one continuous drive across every day. The phone has no such view by
default. It shows ONE day, and the stage filter went by day number alone, so that
connection came along: a line running off the day on screen to a place it never visits,
and on this trip the longest thing on the map by far.

The assembler now says which lines are connections (`lineJoins`, parallel to `lines` and
`lineDays`) rather than leaving each surface to guess, because the day number cannot tell
them apart: it reads the leaving day for both a day's own legs and for the drive out of
it. A stage drops them; the all-days view keeps them, which is the view the setting is
for and is one tap away on the same map, next to the day chips.

Routes read back from an older cache carry no marks, so nothing is known to be a join and
nothing is dropped: those days draw exactly as they did before.
2026-09-17 13:48:18 +02:00
Maurice d07aa80093 fix(roadtrip): the stage map shows its own night pause, and only that one
The moon pill marking where a travel day ends is back on the phone, filtered to the day
on screen. The markers are built from the whole drive, which is right for the desktop
rail and for the all-days view, but a stage draws one day: unfiltered, day 1 carried day
2's pill as well, sitting in the middle of a map that is otherwise entirely day 1. Two
ends on a map that draws one is a question rather than a label.

Off a stage the list is left alone, because in the all-days view every night does belong
to a line that is drawn. The plan tab is unaffected either way: its vias come from the
route calculation, which has no night pauses in it.

This is what the map lost when the pills were dropped altogether: on a day whose drive
ends somewhere other than its last stop, the pill is the only thing that says where.
2026-09-17 13:31:43 +02:00
Maurice 911f3e2583 fix(roadtrip): the phone chain drops the placeholder leg into an automatic night
`dayWindow.stationary()` puts a leg between the last stop of a day and the automatic
night that closes it: same coordinate at both ends, nothing measured, no texts. It is
bookkeeping, not a drive. Drawn as a pill under the last stop it said "No route" — and
before the template was guarded, a bare " in " — under a place the traveller had simply
arrived at.

Matched on the shape rather than on the next stop being a night, because what makes it
not a drive is that it goes nowhere: a day ended at a boundary dragged down the road HAS
a leg with a distance, and that one keeps its pill. A leg nothing has routed yet keeps
its row too, so it can still say it is pending.
2026-09-17 13:26:10 +02:00
Maurice 0b1ca1c98a feat(roadtrip): switch a stop between a pause and a destination from the phone chain
The desktop rail turns a place on the drive into a service stop by its own disc: click
the 3, pick the pump, and the 3 becomes an orange disc while everything below it
renumbers. The phone chain drew the same disc but it did nothing, so the only way to
mark a petrol stop was to open the trip on a computer.

The disc is now the control here too, for the same reason it is there: it is exactly
what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the
desktop's popover under the disc, because a palette aimed at an anchor would land under
the thumb that opened it. The kinds stay discs rather than seven rows of words, each
named for a screen reader. A second tap on the kind a stop already is takes it back to
a destination, and once there is something to undo the sheet says so in words too.

It writes through planner.setRoadtripStopKind, the very call the rail makes, so the
chain redraws off the store with no second code path. Without place_edit there is no
handler and the disc is a plain disc, not a disabled-looking control.

A leg pill with no figures in it is fixed on the way past: a segment that reaches the
chain before its routing round has landed carries metres and seconds but empty texts,
and reading only the texts printed the template with both slots empty, so the pill read
" in " and said nothing. The numbers are formatted when the texts are missing, which is
what the rail does with the same segment, and with neither the leg says it is pending.
2026-09-17 13:10:28 +02:00
Maurice a250fa6339 fix(roadtrip): the phone map loses the stage bar over the dock
It named the day's destination with its picture, its stop count, its arrival and the
stage distance, and it stood there for the whole of the road trip tab. The day and
the distance are in the shell's stage header already, and on a map that fills the
screen a permanent card over the bottom edge costs more than the three facts it
added. Tapping it opened the destination's stop sheet; the chain opens the same sheet
from the row that names it.

The slot stays for the one bar that earns it: while other ways of driving a leg are
on offer, the picker's bar stands there. That wrapper is now mounted with the bar
rather than lying empty over the map's own buttons the rest of the time, and the map
floor lifts only while it is up, so on the stage the round controls stand on the dock
like everywhere else.
2026-09-17 12:58:09 +02:00
Maurice 50f0641ad9 fix(system-notices): a notice waits rather than showing its own key names
The keys come from the server's registry, the copy from the bundle in the browser,
and a deploy parts the two for as long as it takes the page to reload: the service
worker keeps serving the old app while /api/config already reports the new version,
and the release notice for that very version arrives naming keys the old bundle
never had. What is drawn is then system_notice.release_notes.headline where the
headline belongs, on the one notice whose whole job is to be read once per release.

Worse, a dismissal in that window is a real one. The entry is per-version, so
whoever closes the gibberish never sees the notes for that release at all.

So a notice made only of unresolved keys is held back until the reload App.tsx is
already waiting for. Nothing is spent by waiting: a notice is used up by
POST /dismiss and by nothing else. A release notice is judged by the strings its own
two-column layout draws, not by the generic title and body it never shows. Copy
written out in words rather than keyed is left alone, and one resolved string is
enough to show a notice: a notice hidden by mistake is the worse failure.
2026-09-17 12:43:53 +02:00
Maurice 782bb365b5 fix(maps): the phone map carries no credit and no night pause labels
The credit was a round (i) under the locate button, and on a full-bleed phone map a
lone control there is the first thing the eye lands on. It is off on the phone now:
one rule in mobile.css against both engines' containers, so Leaflet, MapLibre and
Mapbox cannot drift apart. The desktop map is untouched and still shows the full
credit with its links. Owner's call, made knowingly.

With the credit gone the band the round controls floated on had nothing under it to
clear, so the 38px credit row comes out of --bottom-nav-h and out of the camera
padding the offered roads are framed with. The controls now stand on the map floor.

The night pause pills go with it. On the desktop one is a drag handle with a hint;
a phone has neither hover to explain it nor room to drag, and the markers come from
the whole drive rather than the stage on screen, so day 1 drew day 2's pill as well.
Both via lists go through the same filter, because the plan tab carries them too.
2026-09-17 12:43:45 +02:00
Maurice 222313d198 fix(roadtrip): closing offers or other roads no longer flies the phone map back to an old stop
A final pass over the feedback commits found two gaps in the phone map.

The planner keeps a point it was asked to show until its next routing
round, and once the fuel offers or the other roads for a leg close it hands
that very array back. MMapArea read it as a new focus, so closing either one
flew the camera to the stop "Show on map" had pointed at, on another day's
stage too. holdFocus now remembers the arrays it already held and only
takes a new one as a focus, with the open picker's own roads as the one
exception.

The all days view still took its pins from the planner's planned list,
which on a phone leaves out booked nights and hidden service stops, so the
drive ended at a hotel with no pin to open it from. It now reads the places
of the stored visits, like the stage does.
2026-09-17 00:36:57 +00:00
Maurice 6a6c7f1882 feat(roadtrip): other ways to drive a leg, on the phone
The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
2026-09-17 00:10:51 +00:00
Maurice 9b415c8ab9 feat(maps): the phone trip map keeps its credit in the bottom right corner
The credit button sat in the middle of the round controls band, beside the
locate button. On the trip map it now has the bottom right corner to
itself, a gap above the dock (or above the stage bar on the road trip
tab), and the band floats one credit row higher, so the switcher, the
compass and the locate button stay on one line.

MMapArea marks its layer as a credit corner and defines the floor the map
ends at; mobile.css places both GL engines' compact control there, keeps
an opened GL credit's button in the corner, moves the Mapbox wordmark into
the same row instead of under the compass, and unfolds the Leaflet credit
to the left of its button. MapAttributionToggle reads the same two
variables with today's values as the fallback, so the other phone maps and
the desktop keep their placement.
2026-09-16 23:21:40 +00:00
Maurice bf0ec85372 feat(roadtrip): badges and a place picture on the phone stage bars, a compact head card
The stage bar over the road trip map joined its figures with middle dots
and led with a bare day colour dot. It now leads with a picture of the
place it names (PlaceAvatar, falling back to the category icon), ringed in
the day colour while day colours are on, and shows the stop count, the
arrival and the distance as badges. The header pill over the tab does the
same with the day and the distance, and leaves out a distance nothing has
measured yet instead of printing 0 m. Both bars carry an accessible name
built from their parts, because pills side by side lose the spaces between
them when a screen reader reads them out.

The badges are a new non interactive MBadge, a span, since every place it
sits in is itself a button. The value rules shared by both bars live in
stageBadges.ts.

The head card at the top of the chain is more compact: the drive and the
stop count ride on the date line as badges, the two clocks are a size
smaller and the separate stats band is gone. The day colour dot is gone
from it: on the list half the line it keys lies hidden under the chain, so
it read as an unexplained status light.
2026-09-16 23:21:40 +00:00
Maurice 9de18634fe fix(roadtrip): a place tapped on the phone stage map opens its stop
The bar over the road trip map names a place, but a tap on it switched to
the whole day's chain and the place never opened. It now opens that stop's
sheet, the one its row in the chain opens. Its name, clock and tap come off
one row (stageEnd), so the bar no longer pairs the destination with the
automatic day end's clock (Kyoto Station 22:00 while the head card said
12:40). The header's list switch stays the way back to the chain.

A pin on the stage went through the plan tab's marker click, which moves
the place selection and brought up the plan tab's place card. On the road
trip tab a pin now opens its stop on the stage card, or the first routed
visit in the all days view, and only a place no routed day stops at still
gets the place card. The handler keeps one identity for the life of the
map. "Show on map" in the stop sheet and the up next card move the camera
through focusRoadtripPoint instead of the selection, picking the stop's
card first when it is not on screen. A pending focus now lets go once the
day moves, so later stages frame their own drive again.

Stage pins come from the trip store instead of the plan tab's map list.
That list carries the plan tab's day declutter and place filters, so after
the all days switch and back most pins of the next stage were missing, and
the unplanned filter or hidden service stops removed stops the chain draws.
2026-09-16 22:49:44 +00:00
Maurice 5b60dda150 feat(roadtrip): edit a stop's place from its phone sheet, title centred on the disc
The stop sheet had no way into the place, so a fixed time could not be set
from the road. A pencil in the header now hands over to the place editor
the plan tab uses, with this visit in context, so Start (the time the chain
anchors on) can be set or cleared there. It shows with place_edit, once the
place row resolves from the whole trip store and the visit carries a server
id. No second writer for the time is added.

Opening the editor on a booked night or a service stop the day lists hide
used to miss the visit, prefill Start from the pool place and write that
over the pin on a plain save, dropping the note too. MPlaceEditSheet now
resolves the visit from the stored assignments.

The header row is centred, so a one line name no longer hangs from the top
of the 34px disc.
2026-09-16 22:23:01 +00:00
Maurice bf03d91345 feat(roadtrip): the phone dry band gets the desk's mascot and reserve lamp
"Tank runs out here" on the phone was a pink card with a black pill. It now
has the desktop band's design sized for a thumb: the mascot on its
skateboard and not enjoying it, a small uppercase warning, and the reserve
lamp as a 44px button. While a search runs or its offers show, the lamp
steps aside for a close (nothing in the band could clear the offer pins
before), and an empty answer leaves a retry.

Which control shows, which offers and which empty sentence is decided in
refuelBandState in refuelSuggestion.ts, next to the fuel and battery words.
The desktop RefuelBand reads the same helper now, with no visual change, so
the two shells hold markup only.
2026-09-16 22:23:01 +00:00
Maurice 79450a5380 fix(roadtrip): head the phone stage with its start, not the first stop's departure
The head card read LEAVE 11:30 over a chain whose first stop is pinned at
10:00 with an hour and a half there. The figure was the departure from the
first stop, a clock no row prints, while ARRIVE was the last stop's arrival.
So the number was the wrong quantity and "Leave" the wrong word for it.

Both figures now come off the arrival column the rows print: START is the
first timed arrival (or the resume point on a morning after an automatic
night), ARRIVE is the last one. The rule lives in stageClocks in the shared
row model and reaches the tab as rt.clocks. The label is a new key,
mobileTrip.rtStart, because roadtrip.stay.leave keeps meaning a stop's own
departure in the stop and stay sheets. Both clocks carry dir=ltr like the
rows.
2026-09-16 22:23:01 +00:00
Maurice bd347f553b fix(maps): the satellite switch answers on the phone, and the compass stops hiding under it
Two faults behind one round button on the phone map.

The base layer switcher that both engines draw in the bottom left corner
sat on top of the GL compass, which started 8px to its left and ran on
under the frosted shell, so it read as a second button showing through.
The compass now stands beside the switcher, placed off the switcher's own
exported inset and size so the two cannot drift apart again.

Satellite never drew on GL because applySatellite gave up whenever
isStyleLoaded() was false and only retried on styledata. That flag is false
while any source has tiles or a setData in flight, which on a phone is the
usual state, and styledata does not fire when those finish. On the road
trip stage every tap met a busy style, because the settings write
re-rendered the shell and handed the map fresh stage arrays. The guard is
gone (the pass only runs after load, and addSource/addLayer only refuse a
style document that is not in yet), an existing layer just flips its
visibility, and a source left without its layer still gets the layer.

The stage data in MMapArea is memoised, so a store write no longer refits
the camera or sets the route, access and place sources again.
2026-09-16 22:22:46 +00:00
Maurice aaff8f9db4 feat(roadtrip): bring the road trip addon to the phone
A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.

The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.

The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.

The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.

Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
2026-09-16 21:55:18 +02:00
Konstantinos Thermos 5f57c63426 fix(journey): make the source_assignment_id migration replay-safe
An unreleased migration has to survive being replayed on a schema where it
already applied — installs skip releases, and a test rewinds a fully migrated
database to the last released version to prove it. The bare ADD COLUMN threw
"duplicate column name" on that replay, which took every migration test down
with it.

Guard the ALTER on pragma_table_info, and add `source_assignment_id IS NULL`
to the backfill so a replay cannot re-resolve a row that has since followed
its stop to another day.

The trips e2e harness hand-builds a minimal journey_entries table rather than
running migrations, so it needs the column spelled out too.
2026-09-16 16:32:02 +03:00
Konstantinos Thermos bd44197974 Merge pull request #2330 from liketrek/feat/pdf-trip-route-map
Feat/pdf trip route map
2026-09-16 16:10:31 +03:00
Konstantinos Thermos 37163e9a41 fix(places): render markdown in the mobile place description
The mobile shell printed place descriptions and notes as plain text while
the desktop rows and inspector run the same field through react-markdown,
so a description written with the formatting bar read as `# headings` and
`**asterisks**` on the phone.

Put the renderer in one shared component instead of copying it next to
each mobile twin, with a clamped variant for list rows that keeps them one
line high the way the desktop sidebar does. A clamped caption sits inside
the tappable row, so its links keep their text and lose the anchor.

Fixes #2337
2026-09-16 15:42:07 +03:00
Maurice 61a8a2765b fix(geo): read the Nominatim override the way the schema validates it
Follow-up to #2379.

NOMINATIM_URL arrives from a compose file or a ConfigMap with whatever
whitespace was typed around it. The schema validates the trimmed string and
calls a blank one unset, the derivation did neither, so " " passed startup as
"use the default" and then became the base URL that cannot be fetched, and a
padded value lost every geocoding call the same way. It trims now, which is
also what the places client next door does with its own override.

The chart could not pass the variable at all: the ConfigMap is an allowlist and
an unknown key is dropped without a word, which is exactly the locked-down
cluster the override exists for.

The docs said what to set and not what it costs. They now say that it replaces
the public service with no fallback, that the endpoint has to speak the
Nominatim API, and that the one-per-second spacing stays either way, so nobody
stands up their own instance expecting it to run faster.

Test IDs follow the file again.
2026-09-16 11:08:07 +02:00
Luffy c0195557d4 feat(geo): support overriding Nominatim endpoint via env 2026-09-16 14:30:31 +08:00
Maurice 06610bd97e Merge pull request #2377 from liketrek/feat/release-notes-430
feat(notices): bring the release notes back on every update
2026-09-15 23:51:17 +02:00
Maurice 05ee3d50e4 feat(notices): bring the release notes back on every update
The release modal takes the 4.3.0 design: the release and its headline cards
on the left, the note and the support links on the right, at the design's own
size. The cards carry small drawings of the features instead of an icon.

The 4.0.0 entry is retired. In its place sits one entry, release-notes, that
every big release rewrites and keeps. It recurs per version with no upper
bound, so every user sees it once on each update, whatever they did with it
before, and a reload of the same version keeps it closed. An integration test
holds that promise through the real entry and the real dismiss route.

All 23 locales carry the new copy and drop the 4.0.0 strings.
2026-09-15 23:50:15 +02:00
Maurice e2bdd2dd08 Merge pull request #2362 from liketrek/dependabot/npm_and_yarn/js-yaml-4.3.2
build(deps): bump js-yaml from 4.3.0 to 4.3.2
2026-09-15 23:11:57 +02:00
Maurice 99783cc8da Merge pull request #2346 from WenyinWei/fix/share-map-clustering
fix(shared): cluster the public share page's markers
2026-09-15 23:06:41 +02:00
Maurice e5d85eda8d Merge pull request #2347 from WenyinWei/fix/share-header-overflow
fix(shared): keep the header's decoration from widening the page
2026-09-15 22:58:10 +02:00
Maurice a9cd47caeb build(deps): bump js-yaml from 4.3.0 to 4.3.2
Only the js-yaml entry changes. The Dependabot commit was built on main and rewrote the lockfile with another npm, which dropped the peer flag from 23 rollup entries and carried two wiki commits that dev does not have yet.
2026-09-15 22:49:10 +02:00
Maurice 8a72d98bc6 test(shared): type the cluster icon size check and tighten the comments
iconSize is a PointExpression, which has no x on its tuple form, so the client typecheck refused the size assertion; it now compares against L.point. The test ids follow the FE-PAGE-SHARED-MAP scheme in the order they run, and the comments around the cluster group say in a line what they said in a paragraph.
2026-09-15 22:47:46 +02:00
Maurice 09e46bf723 test(shared): give the header clip test an id of its own
FE-PAGE-SHARED-040 already names the trip-wide marker colour case in the same file, so the new case moves to 042, and the comment above the clip says what it needs to in two lines.
2026-09-15 22:41:37 +02:00
Maurice f0a4e4c74d Merge pull request #2376 from liketrek/fix/atlas-visit-dates
fix(atlas): date a country's first and last trip from the trips that count
2026-09-15 22:05:34 +02:00
Maurice acd6f1acdf fix(atlas): date a country's first and last trip from the trips that count
The Atlas tooltip took both dates from the start dates of every trip with a
place in the country. One trip showed the month it began twice, the month it
ended never appeared, and a trip only booked could become the last visit of a
country already visited.

Both now come from the trips that give the country its status, places and
bookings alike: the start of the earliest and the end of the latest, with a
trip still under way ending today. The optimistic mark on the client clears the
dates the same way the server does.

The second label rendered the raw key atlas.lastVisitLabel since #1908 removed
it from the locales; the strings are back as they were. The month is built
from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and
showed the month before anywhere west of Greenwich.

Closes #1535
2026-09-15 21:36:34 +02:00
Konstantinos Thermos 48cd46f818 feat(pdf): put the trip's route map in the export 2026-09-15 15:58:36 +03:00
Konstantinos Thermos 4eaf90fd1b fix(journey): a place kept across two days is two days of the journal
The sync engine keyed a skeleton by source_place_id alone, so a stop standing
on two days — the city you land in at dusk and walk through the next morning —
got one entry on the first of them and nothing on the rest.

Key on the day assignment instead. The assignment row rather than the day,
because moving a stop updates day_assignments.day_id in place: keyed this way
an entry follows the move instead of reading as one stop leaving the plan and
another arriving. Existing rows are backfilled to the place's earliest
assignment, and reconciliation adopts an unlinked row rather than annotating
it out from under its author.

Fixes #2329
2026-09-15 15:31:59 +03:00
Maurice 2efa843306 fix(test): the MCP endpoint cases find their own row again
FE-COMP-INTEGRATIONS-004, -015 and -016 took the first <code> and the first
Copy button on the tab and expected the MCP endpoint. Since the API keys card
shows the /api/v1 address with a copy button of its own (#2371), and it sits
above the MCP section, those queries landed on the API card: the URL read
/api/v1 and the click copied it. The cases now go through the endpoint's own
label.
2026-09-14 23:23:22 +02:00
Maurice 53eeba3c1b Merge pull request #2372 from liketrek/fix/dawarich-tester-feedback
fix(dawarich): three things a tester on dev1 ran into
2026-09-14 23:09:33 +02:00
Maurice c1d55c463c fix(dawarich): three things a tester on dev1 ran into
The Atlas country check timed out. It asked Dawarich for a year of visited
cities in one request, and Dawarich computes that over every point in the
window with nothing cached, so a dense recorder never got under the 15 s the
server allows. The year is now asked for a month at a time, one request after
another, and folded back together. The windows sit a second apart because
Dawarich reads the bounds as inclusive.

The recorded route only appeared after a reload. Every Dawarich call from the
browser carried the shared 8 s timeout, so the browser gave up while the server
carried on and cached the answer for the next page load. Calls that make the
server talk to Dawarich now wait two minutes, the Atlas four. On the GL
renderers the draw also gave up whenever isStyleLoaded() was false, which it is
while any tile is still loading, and only retried on style.load. It now waits
for idle. The hazard overlay had the same guard and gets the same wait. The
pill shows a ring while the route loads, so a slow first fetch no longer looks
like "done, nothing there".

A collapsed day now takes its recorded route off the map, following the same
folds the planner already applies to places, the road trip sidebar's included.
2026-09-14 23:00:57 +02:00
Maurice 60cbb41d5f fix(roadtrip): put a corridor hit on the drive from the day before ahead of the first stop
The corridor searches the whole line a card draws, and a card that opens
with a drive from the day before (a night carried over, or the joined road
on connected days) draws that drive ahead of its first stop. The insert
index still came from insertIndexForAlong, which never answers 0, so a
charger found on that stretch was added after the first stop and the day
drove there, turned back to the charger and came the same way again.

insertIndexFor now answers 0 for a hit before the first stop when the card
arrives from an earlier stop, and dropping a hit on the map asks it instead
of computing its own index, so a click and a drop cannot disagree.
2026-09-14 22:43:46 +02:00
Maurice 2ffe4d6054 fix(roadtrip): file a via on the drive between connected days after the stop it leaves
With connected days the drive from one day's last stop to the next day's
first is drawn at the head of the arriving card, in the colour of the day
before. anchorFor only knew that stretch as a night spill, so a click on it
anchored to the arriving card's first stop: the via was stored on the leg
after that stop, and the next day drove out to the point, turned and came
back, while the stretch that was clicked kept its shape.

assembleRoadtrip now names the stop that joined drive leaves from, and
anchorFor treats it like the stop a night drive left from. Placing and
dragging both resolve to the previous day's last stop, which is the anchor
the seam request already reads its vias from.
2026-09-14 22:36:29 +02:00
Maurice 1b49995c15 Merge pull request #2371 from liketrek/fix/api-keys-settings-polish
fix(settings): bring the API keys section and its dialog in line with the rest of TREK
2026-09-14 22:05:57 +02:00
Maurice 25282ea1c5 fix(settings): bring the API keys section and its dialog in line with the rest of TREK
The section stacked a lonely button, a centred line and a small print hint
on top of each other, and the create dialog used bare browser checkboxes
that ignore the accent. The dialog now runs on the shared Modal with
selectable scope tiles, keys render as cards with their scopes as chips,
deletion goes through ConfirmDialog, and the endpoint sits in its own row
with a copy button.

Pressing Enter in the name field with every scope switched off used to
create a key: the empty list was dropped from the request and the server
stored it as a key that reads everything. Creation is now refused until at
least one scope is picked.

A failed list load no longer reads as "no keys yet", the create button
stops at the server's limit of ten keys, a refused clipboard says so, and
SQLite timestamps are parsed so Safari does not show Invalid Date.
2026-09-14 21:59:27 +02:00
Maurice da679e6266 feat(roadtrip): add a stop by hand in the form the rest of TREK adds places in
The dialog built for this last round was its own thing and showed it: the
suggestion list was barely legible, clipped at the bottom, and never more
than two rows tall. It also lacked most of what the ordinary add-place form
has had for years.

So it is gone, and "Add manually" opens that form instead, in a service-stop
mode. The category control becomes the kind of stop and the time spent
there, the costs section is dropped, and an "Add between" row says where on
the drive it goes. Everything else comes along rather than being rebuilt:
the typed-ahead search with its source marks, the coordinate fields for a
charger no index knows, address, website, phone, notes, times, files and the
duplicate check. With the mode absent the form is the same component it was,
which its own suites still pin.

Where the stop lands is worked out when it is saved rather than when the
form opens, because the coordinate only exists once a place has been chosen.
One function answers both what the select shows and where the stop goes, so
the two cannot disagree, and the write goes through the same assignment path
as every other insert, via re-anchoring included: a stop pushed into a day
shifts every via anchored at or after it, and without re-anchoring first the
drawn road runs forward, doubles back and runs out again.

The select recommends rather than merely offering. Every leg of every routed
day is measured against the place separately and they are listed nearest
first, each with its own distance, so the preselected one visibly is the
closest and overruling it is an informed choice. The word they all shared
moved into the caption.

A stop with no position at all is not filed on a drive by guesswork. It
keeps its kind and goes to the trip's places, and the form says so and says
how to change it.
2026-09-14 16:22:18 +02:00
Maurice 68ce177901 fix(test): CHARGING-013 failed on a date, not on a count
The case asserts no availability counter is shown for an ambiguous station, and
looked for one with an unanchored /\d+\/\d+/. The attribution line beside it ends
in the source's updatedAt run through toLocaleString(), and tests/setup.ts only
forces en-US on toLocaleDateString, so that timestamp reads "14.9.2026" on a
German machine and "9/14/2026" on the CI runner. The regex matched the second
one. Green where it was written, red on Linux, and red for a reason that has
nothing to do with charging.

Anchored at the start instead. Both places that print a count begin with it, so
the case still says what it meant to say.
2026-09-14 14:26:15 +02:00
Maurice 3841412b22 fix(roadtrip): the two search actions stop cutting their labels in half
"Add manually" did not fit beside "Search" and rendered as "Add manu…",
which is neither the label nor a shape anybody recognises.

The button face now carries one short word, with the full sentence kept as
its accessible name and its tooltip, so nothing is lost to a reader or a
pointer. Every locale got its own short form rather than a clipped version
of the long one: Selbst, A mano, Ručně, Elle, 手動.

Below two hundred pixels the row drops both words instead of shortening
them, and the two actions are their icons. The width is the row's own,
measured, not the window's: this column is resizable, so it can be narrow on
a wide screen and a media query would answer the wrong question.

The search button also stops restating the progress. Its label used to grow
into "Searching 3 of 12" while a run was on, which is the longest string in
the panel and the very sentence the progress row directly underneath already
prints next to the share as a figure. It keeps its word, disabled, with the
spinner in front of it.
2026-09-14 13:50:44 +02:00
Maurice 049fb76965 Merge pull request #2366 from liketrek/fix/roadtrip-feedback
feat(roadtrip): the testers' round, and a defect it uncovered
2026-09-14 13:33:36 +02:00
Maurice 54b94cf9e3 docs(wiki): the manual stop, clearing a search, and charging before the add
Three panel changes and one new MCP tool that the pages did not describe
yet. Also records the two rules the code enforces and a reader would
otherwise have to discover: the charging panel is fetched once per opened
dialog rather than per row of a result list, and the lookup tool carries no
power rating or socket count because those are OpenStreetMap tag data that
travels with the corridor result instead.

The wiki syncs on a push to main, so this rides along until the next
release rather than appearing now.
2026-09-14 13:22:26 +02:00
Maurice b058b63f6e fix(roadtrip): the manual-stop dialog looped on a hint rebuilt per render
Three things the first CI run found, all of them in the new tests rather
than in what they cover.

The dialog's search effect depended on the location hint object. Its own
hook memoises, so nothing looped in the app, but the effect sets state on a
query too short to ask about, and a caller whose hook hands back a fresh
literal per render turns that pair into a render loop that never yields. The
suite mocked it exactly that way, and the worker hung before a single test
reported, which is what took the client coverage job past half an hour. The
hint is three numbers, so the effect now turns on those and reads the value
out of a ref when the request goes out. The mock hands back one object, the
way the real hook does.

The suite also installed fake timers in a beforeEach, which deadlocks
against React's scheduler and Testing Library's own waiting. Every wait is
a waitFor now, and the two cases that have to prove something did not
happen sit out a real pause. The debounce boundary is no longer asserted
against a wall clock, because a busy runner stretches any such window.

The charging fixtures are pinned to a fixed instant and the service reads
the real clock against a twenty-minute freshness window, so every answer
through it was stale by construction and available came back null. Date.now
is pinned for those tests, never the timers, since the requests carry an
AbortSignal.timeout that has to keep running.

And the unidentified-station test counted its two "no reliable data" lines
before the answer landed: the price line already says it while the request
is out, so the count settled on one and passed for the wrong reason.
2026-09-14 13:20:21 +02:00
Maurice f24827e7ed Merge pull request #2365 from liketrek/test/dawarich-coverage
test(dawarich): the integration that shipped without a suite
2026-09-14 13:02:35 +02:00
Maurice 00d00be1c0 feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked
for it while still deciding. The availability and the published tariffs were
reachable only for a saved place: the route takes a places id, and a hit
found along the route has no row yet.

ChargingService.load already needed nothing but a coordinate and a name, so
it now has a public method that says so, behind a trip-scoped route with the
same guard stack, a Zod contract in shared, and the parallel MCP tool in this
same change. Same service, same caches, so a station looked at in the dialog
and then added keys the entry already fetched. Trip-scoped even though the
trip is not part of the question: without :tripId the trip access guard goes
too, and what is left is an open proxy in front of somebody else's public API.

It renders in the add dialog, and only while the selected kind is charging,
so a fuel or food stop costs nothing and one opened dialog is one lookup.
Deliberately not per result row: the corridor search publishes box by box,
every mounted panel owns a refresh timer, a cache miss can cost seven
upstream requests, and a station in a dense charging park is the case the
upstream matcher refuses as ambiguous.

Power and socket counts are not in this answer and were not asked of it.
They are OSM tag data and already ride along on the corridor row for free.
2026-09-14 12:28:47 +02:00
Maurice accdd5905e feat(roadtrip): add a stop the search never found, and clear the ones it did
Four things testers asked for in the "Along the route" panel.

Add manually. The corridor search reads OpenStreetMap, and a good share of
the chargers standing at a motorway junction are not in it, Tesla
Superchargers among them. The way round it was to leave road trip mode, add
the place under Days, drag it onto the right day, come back and mark it a
charging stop. The Search button now shares its row with a second one that
asks the two questions those four steps answered: which place, and where on
the drive. The place comes from a debounced typeahead over the place search;
the position from anchorFor, the same projection a via is placed with, and
it is offered as a dropdown of every leg of every routed day so it can be
overridden. Unlike a via, a place far from the drawn line is not refused,
only flagged: that place is the reason this dialog exists. From there it
hands over to the stop popup a corridor hit already goes through, so the
kind and the dwell are decided once for both paths and the write stays
saveStopDraft.

Clear results. A search that turned up the wrong thing left its hits in the
list and its pins on the map, and the only way out was switching to the day
plan and back. The search hook has exposed a clear() since it was written
and nothing ever called it. A control in the results header does now, and
takes the four narrowing states with it, because they were set for a
question that no longer has an answer.

Charging as the default for an electric car. "Looking for" opened on Fuel
whatever the vehicle. It opens on Charging when the trip's vehicle is
electric, and is unchanged for combustion, for an empty value and for a
vehicle nobody set, so nothing moves for anybody who never opened the
dialog. Seeded once, never over a category the traveller picked or a search
in flight.

Clicking a result moves the map to it. The row had no click handler at all;
only the trailing Add button did anything. The badge and the text are one
activatable target with that button as its sibling, which keeps the li a
list item and avoids a button inside a button, and it goes through the same
focusRoadtripPoint the rail uses, which closes the refuel and alternatives
panels first because those outrank the camera.
2026-09-14 12:28:30 +02:00
Maurice f4bbb0fe5d fix(roadtrip): the alternatives list compared two routers' drive times
The ways of driving one leg come from OSRM, except the toll-free or
motorway-free offer beside them: the shipped OSRM hosts are built without
excludable road classes and answer exclude= with HTTP 400, so that one is
asked of the avoidance router instead.

Their figures were then read against each other. The quickest was elected
across both, which is what decides the road drawn as the one being driven,
and every other road was labelled with how far behind that election it sat.
The two engines agree on the road to a tenth of a percent and not at all on
how fast it may be driven: over twelve European legs the per-leg time ran
from 14.7 % under OSRM's to 13.2 % over, with the sign following the region.
On a Spanish motorway leg that gap alone is enough to crown a toll-free
detour and call the motorway somebody is on the slow way round.

An offer now carries the engine that timed it. It takes no part in electing
the quickest, no difference is printed across the two, and the bar says where
the figure came from. Each route still reports its own time; only the
subtraction is refused. An instance running its own OSRM through the MLD
pipeline answers exclude itself, so there both figures are one engine's and
the comparison works exactly as before.
2026-09-14 12:28:12 +02:00
Maurice a6e8c66609 test(dawarich): the integration that shipped without a suite
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.

What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
2026-09-14 10:39:49 +02:00
Maurice b2384a7523 test(places): the delete doubles answer in the shape the service now uses
remove and removeMany report what the nights they cancelled took down with
them, so the stubs in the controller and plugin-RPC suites report it too. Plus
a case for the announcement itself, which had none: place:deleted says nothing
about a partner booking or an expense linked by reservation_id.
2026-09-13 23:49:01 +02:00
Maurice 8cddd445ba test(roadtrip): the assemble spec holds up under shared's stricter typecheck
noUncheckedIndexedAccess makes every index access optional, DriveLimits wants
its two minute ceilings named even when they are null, and the distance unit is
metric/imperial rather than a unit label.
2026-09-13 23:49:01 +02:00
Maurice 39d832c772 test(journey): the entry picker takes clips, so the test may not insist on stills
FE-PAGE-JOURNEYDETAIL-105 still looked for accept="image/*" on the editor's
file input after it learned to take video (#2341), which is the one red test on
dev. The camera input beside it stays image-only and the test says why.
2026-09-13 23:49:01 +02:00
Maurice f4380727c7 fix(roadtrip): the range warning and the per-leg figures in the server plan
deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.

The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
2026-09-13 23:49:01 +02:00
Maurice 61880ce7d6 fix(roadtrip): the vias, the alternatives, the pacing and the missing warning
Choosing another road on a leg that already carried a via appended the new
point behind the old one, so the day drove south to the first and north to the
second: a zigzag matching neither the line drawn under the button nor the
distance printed on it. The alternatives are computed for the two bare
endpoints, so the write has to replace the leg, which is what the direct branch
beside it already did.

A day's vias were only loaded while road-trip mode was being looked at, and the
three handlers that re-anchor them after a stop is added, removed or reordered
are reachable from the place inspector in both modes. With the switch off they
computed their correction from an empty list and wrote nothing, so a deleted
stop left the detour on a leg nobody chose, for the traveller and for every
collaborator. They are loaded whenever the addon is on now.

Seam routing paced itself by sleeping after each request, and storing a result
shortened the key its own effect depends on: React tore the effect down
mid-sleep, the cleanup aborted it, and the next request went out immediately.
Thirteen seams left at round-trip speed, the shared routing hosts refused the
tail, and a refused seam changes no state so nothing ever retried it. The wait
now happens before the request, against a clock that outlives the effect.

The "avoidance not honoured" badge could never appear on the rail: the field
behind it was published every round and never written. Only the second engine
reports what it achieved against what was asked, and the rail plans in the
browser, so it never saw the server's own copy — a motorway crossing read as a
motorway-free drive.

Two smaller ones. `trip.toast.loadError` has been called since the first commit
and exists in no locale, so both call sites printed the key itself; it is
written now in all 23. And the phone shell honoured the road-trip session flag
while having no rail, no drive lines and no switch to leave it, so narrowing a
desktop window took the trip overview away and listed every booked night twice.
2026-09-13 23:49:00 +02:00
Maurice c8cb88133f fix(maps): the projection, the labels and two unbounded reads
A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.

Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.

Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
2026-09-13 23:49:00 +02:00
Maurice 9deedb80ea fix: receipts, cached places, chat previews and the Google-route tool
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.

The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.

The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.

import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
2026-09-13 23:49:00 +02:00
Maurice f0e4c9a33d fix: five small defects the audit turned up in the release
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.

update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.

The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.

A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.

An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.

Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
2026-09-13 23:49:00 +02:00
Maurice 89bae414ff fix(offline): a missing driving-settings row no longer discards the trip bundle
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
2026-09-13 23:48:59 +02:00
Maurice c42cf5477b fix(public-api): a key narrowed away from days stops getting days back through places
Places, notes and reservations hang off days, so asking for one of them implies
the day container. That implication ran ahead of the grant: a key minted with
scopes ['trips','places'] and deliberately no 'days' still received every day's
title and the day's own free-text notes, either by naming places or by naming
nothing at all, since an absent include narrows to the granted sections and
lands back in the same branch. Asking for days by name was a 403 the whole
time, so the two routes disagreed about the same fields.

The grant now reaches the service alongside the narrowed include. The container
is still implied, because the places inside it are useless without their date,
but the day's title and notes only go out to a key that was granted days. A key
with full access is unaffected: it is granted days whether or not it asks.
2026-09-13 23:48:59 +02:00
Maurice 54e5525568 fix(accommodations): a booking that moves carries its stop instead of rebuilding it
Correcting a booking's date deleted the day stop it had mirrored and inserted
a fresh one. A day stop is not just a day and a place: its participants and any
road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its
hour and its end-of-day flag live in its own columns. All of it went, silently,
for an edit that only meant to move the night one day on. The row is carried
across now, seated by its check-in the same way an insert would be, and the
delete-and-insert is kept only for the case where the target day already holds
that place under a stop of the traveller's own.

Three more holes in the same mirror, found alongside it:

Dropping a night while keeping the place cleared accommodation_id server side
and told nobody. Days hides a stop that carries a booking id, so on every other
screen the place stayed invisible on a day it was standing on.

A night is seated by its check-in, which renumbers the stops around it, and
nothing said so. The day that changed now sends its order along.

Deleting a place cancelled the nights booked there, which takes each night's
partner booking and that booking's expense with it, and announced neither. The
Bookings list kept the reservation and the Costs total kept the money until a
reload, and an expense linked by reservation_id is not one the place-linked
sweep finds.

Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening
a trip past a booking's last night cascaded the booking away and left the stop
pointing at nothing, hidden from Days for good while the route still drove to
it. Migration 234 clears the ones already out there and adds a trigger, which
SQLite fires even when the booking went down with a cascade. And a duplicated
trip now carries the stop's booking id, the booking's check-in window and a
place's fill_percent, none of which the copy took along.
2026-09-13 23:48:59 +02:00
Maurice 0cc6dcf8aa Merge pull request #2360 from liketrek/fix/oidc-exchange-binding
fix(oidc): bind the SSO auth code to the browser that earned it
2026-09-13 22:09:59 +02:00
Maurice a2585beb13 docs(wiki): the SSO exchange takes a cookie as well as the code 2026-09-13 22:05:54 +02:00
Maurice 803a7cb64a fix(oidc): bind the SSO auth code to the browser that earned it
The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.

The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
2026-09-13 22:03:24 +02:00
Maurice 9cb74e22f6 fix(dawarich): a stay's length was sixty times too short
Dawarich's `duration` is minutes. It writes the column as
`duration_minutes = ((ended_at - started_at) / 60).to_i` in
`app/services/visits/create.rb` and serialises it straight through; the merge
service rounds the same way. TREK read it as seconds and divided by sixty
again, so an eighteen-minute stop reached the suggestion panel as "0 min" and
an afternoon somewhere as "2 min".

Found by connecting a real Dawarich 1.14.5 and comparing: a visit spanning
11:00:00 to 11:18:45 reports `duration: 18`.

The span between the two timestamps is what the panel uses now. It agrees with
the arrival and departure shown beside it and it survives whatever unit a
future Dawarich picks; the reported figure only stands in when `ended_at` could
not be parsed, and is read as minutes there too.
2026-09-13 21:38:20 +02:00
Maurice 8eb936f46f fix(test): the places e2e knows the table the delete path reads
Deleting a place cancels the nights booked at it, and that query hits
`day_accommodations`. This file builds its own minimal schema and did not have
the table, so every delete answered 500 and three cases failed. The assignments
e2e was given it for the same reason; this one was missed.

This is the failure that has been keeping Server Coverage red on dev.
2026-09-13 21:25:07 +02:00
Maurice 6e8aa3ab26 fix(dawarich): say why the address could not be reached
The server works out the reason and names it: a rejected certificate, a name
that does not resolve, a timeout, or the SSRF guard refusing a private address
along with the environment variable that would permit it. All of that reaches
the client as `errorDetail`, and the panel dropped it, leaving one sentence that
reads the same whether the host is off or TREK refused on purpose.

A tester could only report that it did not work. The panel shows the reason
under the message now; the toast stays one line, and the vague codes stay vague
so an English sentence from Dawarich does not land on a German install.
2026-09-13 21:19:42 +02:00
Maurice 76f45512d1 feat(admin): the phone's API-keys card, rebuilt like the desktop tab
The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.

So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.

The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.

Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
2026-09-13 21:07:55 +02:00
Maurice c2751d9c80 fix(journey): declare the three field flags on updateJourney
The allow-list learned them and the signature did not, so the only callers that
name them are the tests and only `typecheck:tests` saw it. `typecheck` covers
src alone, which is why this was green locally and red in CI.
2026-09-13 20:44:24 +02:00
Maurice f8ce80da32 fix(mobile): the pills and the badge above them on one ground
Both are white at 28 percent, and they did not look it. The pills carried a
backdrop blur of their own, and a second backdrop-filter inside the panel's own
opens a new backdrop root, so the same colour composited darker one row down.
The panel already blurs everything behind it.
2026-09-13 20:41:14 +02:00
Maurice cbc28fad88 fix(journey): a dead translation key, and two cards that spoke differently
`common.errorOccurred` is in no locale. It sat in the reorder failure toast
already and I copied it into three more places, so a failed dismiss, restore or
reorder printed the key at the reader. They all say `common.errorTitle` now.

The suggestion mark on a phone card was the word spelled out, which took a third
of a 136px card and pushed the date off it; it is the same plus the desktop
suggestion card offers, and the card's italic dimmed title carries the rest.
Dismissing a suggestion from the entry sheet has its own full-width row above
the buttons rather than a two-line label wedged between Delete and Cancel.

And two cards that should have been one design: the dashboard's spotlight trip
wrote its facts in capitals on a panel of dark glass, the journey hero wrote
them in sentence case on a gradient with its badge loose in a corner. The
journey card is built the way the trip card is now, and the trip card's three
pills are set like the DAY x OF y badge beside them, a size down and on the
same ground.
2026-09-13 20:32:49 +02:00
Maurice 15aefa449e feat(journey): the marker card, and what testing it turned up
Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
2026-09-13 19:52:14 +02:00
Maurice c2707b7eff feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or
rest on a misunderstanding, one cannot work as described, and three would take
away more than they give; the rest are here, plus two things the list did not
ask for.

**The entry you write on the move now has a name.** Quick capture hid the title
field outright and offered a note instead, so every entry caught on a phone
arrived nameless and a day's timeline read as a column of identical
placeholders. One line of input; it is the single change in this branch that
users will feel most.

**The map stops yanking.** Stepping through the timeline called flyTo with a
zoom floor of 12, so reading a journey from a country view snapped to street
level on the first scroll and stayed there — every marker filling the screen,
and no sense of where anything was. Both renderers pan now and leave the frame
the reader chose. The initial fitBounds still decides how close a journey opens.

**The phone timeline is photos.** A 64px thumbnail beside three lines of text
spent the card on a story nobody reads at that size and truncated the title
after a few characters. The picture is the card now, with the name and place on
it, the country's flag and the date in the corners, and the day carried as a
colour rather than as a number nobody was reading. Above the carousel, a
segment per day: it says how long the journey is, how far in you are, and takes
you to day nine without swiping past days three to eight.

Everything else, briefly:

- A plus in each day header, starting a draft on that day instead of on today.
- A journey opens on today when today is part of it, and at the beginning when
  it is not — a journey that ended last spring is something you read.
- Suggestions can be waved away one at a time. The row survives so the trip sync
  does not offer the same place again; journey settings brings them all back.
- Mood, weather and the pros/cons list can be switched off per journey. Nothing
  written is erased — the form stops asking, the values stay.
- The weather fills itself in at the desk the way it already did on the phone.
  The date picks the source, so writing up last Tuesday gets last Tuesday's.
- Enter opens the next pro or con, below the one you are in.
- The verdict moved behind the story's fold, and the fold itself came in from
  nine lines to four.
- The external picker lost the count it was printing twice and some padding it
  could not afford inside the editor.
- The phone can reorder entries within a day at last; the desktop arrows finally
  have translated labels.
- Marker labels are off where a carousel already names the entry, and on where
  they are the only name a marker has.
- Search over a journey's titles, stories, places and tags, accent-blind.
- An entry knows which country it happened in, resolved once from its
  coordinates against the bundled boundaries.

Not built, and why: the photos-for-markers idea needs clustering first or thirty
markers become soup, and the active entry's photo is already on screen in the
card below. Removing mood, weather and the verdict outright would take them from
everyone who uses them, so they became switches. `capture` on the file picker
takes the photo library away on a phone, which is why the camera is a second
input; it is hidden on desktop now instead. The suggestion strip in the new-entry
flow would be a third surface over the same data — tapping a suggestion already
opens the editor filled in.

The one gap the list missed and this branch does not close: Journey is the only
domain with no offline path. That belongs in its own change.
2026-09-13 18:45:57 +02:00
Maurice 6edb4462f9 style(collections): the label chip rounded like the rating beside it
Two badges in one row read as one set only if they share a shape. The rating
was a pill, the label a rounded rectangle.
2026-09-13 17:06:35 +02:00
Maurice 27ce4df6f5 style(collections): the place panel as things on their own ground
The labels tried the cover photo and lost: a blue chip on a blue sky is simply not
there, whatever shadow it carries. They sit beside the rating now, both as badges
on one row — two answers about the place, side by side, each legible.

The note takes the full width on a ground of its own. Loose in the column it read
as another paragraph of the panel rather than as something somebody wrote about
this place.
2026-09-13 17:02:17 +02:00
Maurice 1a95157b3a feat(collections): say what a place is without having to click it
Asked for in the feedback: a wall of round photos, and no way to tell which is
which. "Name and displaying any custom rating would be very beneficial to quickly
seeing what is around you that is of interest."

The collections map had hover switched off. The trip map has told you what a
marker is all along, so it is the same card, now shared instead of written a
third time — it already existed twice, once per renderer, and the two had drifted
in how they resolved the category icon.

The card gains a rating, and so does the marker itself: an amber disc on the
corner where a trip's day numbers sit. The two never collide, because a numbered
stop is one already planned into a day and this answers the question asked before
that. Nothing is drawn for an unrated place, which is most of them.

Also from this round: the place panel's labels move up beside the name, where a
label belongs — read three rows down they looked like a property of the rating
above them — and the rating gets a ground of its own so a line of stars stops
reading as another row of text.
2026-09-13 16:49:20 +02:00
Maurice 8fc788fb37 style(collections): the place footer is one row of things you do
Getting directions sat up by the address, in a pill of its own, while editing and
copying sat in the footer as square buttons. All three are things you do WITH the
place, so they belong together and should look like one set. Navigation moves
down beside them, and the footer takes the shape the navigation button already
had.
2026-09-13 16:35:31 +02:00
Maurice de3b2a6ac7 fix(roadtrip): a check-in sets the hour when nothing before it does
Book a night to check in at ten, pin a stop to three in the afternoon behind it,
and the rail said 13:46. True enough as arithmetic — with no hour of its own the
chain works the first stop backwards out of the first one that has one, so three
minus a fourteen minute drive minus an hour standing there — and nothing anybody
asked for.

A door that opens at ten, with nothing ahead of it deciding otherwise, IS the
hour: the day starts there and runs forwards. Reached later it stays what it was,
a floor, and a floor below the arrival changes nothing — which is what keeps a
hotel behind an afternoon stop at its real arrival instead of dragging it back to
its check-in.
2026-09-13 16:22:07 +02:00
Maurice fcee6bd2c5 fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.

The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.

And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
2026-09-13 16:19:57 +02:00
Maurice c351a0471c fix(accommodations): a check-in decides where in the day the night sits
The stop went to the end of the day, always, on the reasoning that a night ends
the day. It usually does — but a check-in at eleven says the traveller is at the
desk before whatever they pinned to the afternoon, and putting the stop behind it
built a drive that reaches the hotel at seventeen past five after a sixteen
hundred it was booked around. The order looked like the traveller's and was not;
it was this code's default.

A booking with a check-in now takes the place its hour asks for: ahead of the
first stop pinned later, and still last when nothing is. Editing the check-in
re-seats it, in both directions, read off the chain rather than recomputed —
the index a fresh insert would get is not the index a row already holds.

Stops without an hour of their own are passed over in either direction. Their
place in the chain came from the traveller, not from a clock, and a night has no
business pushing past one.
2026-09-13 16:06:11 +02:00
Maurice 3f6d1e8ed7 fix(accommodations): re-export the module, not the provider inside it
The container refused to boot: Nest will not export a provider that belongs to an
imported module, and the service moved into the domain module in the commit
before. Re-exporting that module gives every importer the same service it always
got, and the wiring test now asserts the shape that actually boots rather than
the one that only typechecks.

Neither tsc nor the unit tests could see this — the module metadata is only read
when a container is built. The e2e harness does build one, and that is what
catches it.
2026-09-13 15:50:58 +02:00
Maurice 46aa0c6b0a fix(map): the base layer switch stays in its corner when a place opens
Opening a place sent it from the bottom left of the map to the top, which reads
as the control moving house rather than as room being made. Nothing was in its
way: the inspector is a card centred between the sidebars and at most 800 wide,
so the corner it was clearing is one the card never reaches.

Both renderers, since the switch is not supposed to move when an instance changes
its map.
2026-09-13 15:41:46 +02:00
Maurice 77a16d6f26 fix(places): deleting a place cancels the nights booked at it
A stay whose place was deleted kept its row with place_id NULL: still drawn in the
day header, still naming a hotel through its partner booking, and pointing
nowhere. Nothing could reach it any more and nothing cleaned it up.

It goes with the place now, through the accommodations domain rather than a
DELETE in the places path, because a stay takes its partner booking, that
booking's expense and the day stop it wrote with it — none of which is the places
domain's business to know. Both delete paths, single and bulk.

The service moves into an AccommodationsDomainModule, the assignments-domain
precedent: the service never touches PlacesService, only the MCP tool that writes
a place and a stay together does, so it can sit in a module PlacesModule is free
to import without closing a loop. AccommodationsModule keeps exporting it, so
every existing consumer is unchanged.
2026-09-13 15:36:02 +02:00
Maurice 7b50692372 fix(roadtrip): a check-in is a door opening, not an appointment
It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.

The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.

Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
2026-09-13 15:28:48 +02:00
Maurice 62d4cdcc18 fix(accommodations): catch the nights the upgrade itself could not reach
A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.

The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
2026-09-13 15:19:33 +02:00
Maurice a89ad06ba5 fix(roadtrip): a night is over in the morning, not two driving days later
With daily travel times set, a stay was spent only against the hours the window
is open. Twenty-four hours then took two of them to run down: a single night
ended on the day after next, and every stop behind it moved with it.

Night time is time. The hours between one window closing and the next opening now
count towards the stay, so a night that starts just after eight is over just after
eight the next morning and the day carries on from there. An eleven-hour visit
from eight is over at seven in the evening rather than saving its last hour for
tomorrow — the traveller is still there after the driving stops, they simply do
not drive on until the window opens again.
2026-09-13 14:42:54 +02:00
Maurice 7197ec1c96 fix(roadtrip): a stay that outlasts the day takes the road with it
Three things the day boundary was swallowing, all from the same stay.

The stop after it stayed on yesterday. It is reached a full day later but at a
later reading on the clock, and the only rule watching for a new day was "the
clock went backwards" — which that never does. The schedule's own count knows,
as long as it is read as a difference: a day whose first arrivals came out
negative is lifted whole, and reading the lifted offsets raw once moved an early
stop to tomorrow while leaving the late one behind. It now crosses over properly,
marked as having set off the day before.

The morning after started at the exact minute the stay ended, as if the road to
the first stop took no time. The carried minute is a departure; the drive from
there is added to it.

And that drive was invisible. It was on the map and in the day's total, but the
rail jumped straight to a stop at 08:11 after a stay that ended at 08:00. It gets
the same band any other drive gets, above the stop it arrives at — except where a
stop crossed over, which already draws that road under its own block.
2026-09-13 14:34:52 +02:00
Maurice 3d201e1ced fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock
A check-out is the LATEST the room has to be handed back, never the earliest
anybody may drive on, so it says nothing the chain can use. It shaped four things
it had no business shaping: the departure the day was built from, the length the
rail printed for a booked night, a day-selector and a clock field in the stop
dialog, and the branch that sent a night's STAY badge to the booking form because
its length was not editable here. All four go. The booking keeps its check-out;
it is a booking detail and lives under Days, where it is entered.

What replaces it is the thing that was always the honest answer: how long you
stand there. That is the traveller's own statement rather than a deadline, and a
stay running past midnight now carries the clock into the morning after — a stop
pinned before it ends keeps its hour and collects the late finding, as it would
from any leg it cannot make. A stay spanning whole days walks the clock forward
over them. An ordinary evening hands on nothing: the night between two days is
not a wait somebody is serving out.

The stay dialog goes to a full day rather than four hours, since it is now the
only place a night is given its hours, and offers eight and twelve as presets.
Every stop answers the same question the same way.

Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so
the first time each dialog was ever opened a pale block flashed under the planner
and never again once its chunk was cached.
2026-09-13 14:23:49 +02:00
Maurice f4809a67d3 Revert "fix(roadtrip): a night booked into the next morning holds that morning"
This reverts commit d4677bdea, and keeps the departure chip's icon.

The premise was wrong. A check-out is the LATEST moment the room has to be
handed back, not the earliest one you may leave: whoever wants to set off at
seven checks out before that or leaves the bags at the desk. Reading it as a
floor turned a perfectly good plan into a finding, and no amount of wording
around that would have made it true.

The chip keeps the door-out mark. That part says what it always said, only in a
symbol that is not a second clock face beside the arrival.
2026-09-13 14:01:18 +02:00
Maurice d4677bdeab fix(roadtrip): a night booked into the next morning holds that morning
Every day was scheduled from nothing, so a booking that runs into the next day
ended at midnight as far as the chain was concerned. Check in at 08:00 and out
at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00
that morning, with no sign that the room was still yours.

The day a check-out falls in now starts at the check-out rather than at nothing.
A floor, never a move: a stop with a clock of its own keeps it and collects the
late finding any leg it cannot make in time would give it, so a plan that says
07:00 still says 07:00 and merely admits it does not add up. A stop with no clock
starts at the check-out instead of having no time at all.

Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday
alone, because you sleep there, you do not sit there.

The departure chip takes the door-out mark instead of a clock face. Beside an
arrival time, a second clock read as a second arrival.
2026-09-13 13:23:32 +02:00
Maurice 6845db4cb8 fix(roadtrip): a check-in is a time somebody chose, so print it as one
The rail prints an hour in ink when a person picked it and in grey when the chain
worked it out. Every scheduler anchors on `time ?? checkInTime`, so a booked night
starts its day on the check-in exactly the way a pinned stop does — but two of the
three then asked only about `time` when labelling the result, and printed that
chosen hour grey.

The answer lived in three places and two of them disagreed with the anchor they
had just used, so it is one function now. Nothing but the rail reads the flag.

Only visible since a booked night became a stop of its own; before that no stop
carried a check-in.
2026-09-13 13:10:58 +02:00
Maurice 274dbea069 fix(planner): hide the booked night where the day list is actually drawn
The filter sat on the planner hook's assignments, but the day sidebar is handed
storedAssignments straight from the store, so it never saw it. The hotel was
listed as an ordinary place right under the overnight block naming it, on every
day someone booked a night for.

Filtered in getDayAssignments now, next to the service-stop filter that is there
for the neighbouring reason. That is the list itself, so the reorder and undo
paths keep reading the full day and nothing shifts underneath them. The phone
was never affected: its timeline reads the hook's copy.
2026-09-13 13:04:19 +02:00
Maurice d0d3f455a8 fix(planner): let the day list see which stop a booking wrote
The row shapers name every column they pass on, and accommodation_id was in
none of them. So the field the day list hides on never reached the client, and
the booked night was listed as an ordinary place next to the overnight block
that already named it. The stop was correct in the table the whole time; the
three read paths just dropped it on the way out.

The stamp on the freshly written stop had the same hole from the other side: the
INSERT went in without the booking id and an UPDATE added it afterwards, so the
row handed back in the answer, the one the client that booked the night puts
straight into its store, carried none. It goes in with the INSERT now.

The shared link gains the same filter. It has no road trip view to need the stop
and already shows the booking as its own chip on the day, so the row was only
ever the same hotel twice.
2026-09-13 12:59:19 +02:00
Maurice e3471e2dc8 fix(reservations): a night booked on the form belongs on the drive too
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.

It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.

Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
2026-09-13 12:38:21 +02:00
Maurice eff4605ef3 test(accommodations): cover the paths the day stop added, and the ratchet fell on
Six lines and four functions the mirror brought in were never entered by a test,
which is what took the domain under its coverage floor.

The keepStop branch of a delete, the plugin surface's own update, and the
transaction that has to roll a place and its stay back together. Plus every
sender a surface hands announceMirror: REST and the three plugin methods build
one each, and a fixture that swallowed the call let all four go unexercised --
the one link between a stay write and the sockets everyone else is watching.
2026-09-13 12:12:38 +02:00
Maurice edf0538260 fix(roadtrip): keep a booked night out of the day list, and off the traveller's stops
Three things the first pass got wrong.

A stop a booking puts on the day is for road trip mode, which needs the
hotel in the driving chain. The day planner already shows that booking as
its own overnight block, so the row there would be the same hotel twice.
It is hidden in Days whatever the service-stop switch says, and the switch
goes back to meaning what it says: the petrol stations the traveller added.

Cancelling a night from the road trip popup took the stop with it. That
control means "this is a pause now, not a night", and the stop sits
mid-drive where re-adding it would land it at the end of the day. The
booking now hands the stop over instead of deleting it.

Moving a booking whose stop belongs to the traveller put a second one on
the new day and left the first behind, which is the duplicate this change
exists to remove. Only a booking's own stop moves now. Stays booked before
this shipped get theirs from the migration, not from the next edit they
happen to receive.
2026-09-13 01:42:52 +02:00
Maurice 462a208fdf fix(planner): drop the hotel bookend leg when the hotel is the edge stop
The day's accommodation is drawn as a leg to the first and from the last
waypoint. Now that a booked night puts its hotel on the check-in day as a stop
of its own, that last waypoint IS the hotel on most days, and the leg drawn to
it is a round trip of zero kilometres: in the map line, in the sidebar's leg
list and in every exported directions link, which carried the hotel twice in a
row. The defect already existed for a night booked in the road trip; the change
above would have made it the normal case.

Both bookend rules now decline a leg whose edge stop sits at the hotel's own
coordinates, which covers the map, the sidebar, the phone route and both export
links from one place. Compared by coordinates rather than ids, because the
bookend comes off the stay row and the waypoint off the assignment.
2026-09-13 01:13:41 +02:00
Maurice 5e0245046d feat(planner): show the stop a booked night writes without a reload
The server announces the day stop over the socket like any other assignment,
but the socket deliberately skips the session that sent the request, so the one
person who could not see their new stop was the one who had just booked the
night. That looks exactly like the bug it fixes.

The stay writes hand their answer to one applier, which folds the stop into the
store through the same path the socket uses, so the IndexedDB write-through
happens either way and the dedupe by assignment id comes for free. Three
surfaces call it: the day-detail picker, its edit branch and the phone sheet.
The road-trip flow does not, because it writes its own assignment before the
stay and there is never a mirrored stop to apply.
2026-09-13 01:13:33 +02:00
Maurice f0e1da2d81 feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.

Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.

New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.

The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
2026-09-13 01:13:25 +02:00
Maurice 3ab5ddaacc Merge pull request #2349 from liketrek/feat/dawarich-integration
feat: Dawarich integration
2026-09-13 00:05:05 +02:00
Maurice 8a28d4dd4a test(server): stop racing supertest's own listener in the scope e2e
Three cases fired their reads through `Promise.all`. The Nest app under
test is never told to listen, so supertest binds an ephemeral port on the
first request; four of those at once race four `listen(0)` calls on the
same server and the losers come back as ECONNRESET. It held here and
failed on a loaded runner. The reads run one after the other now, which
is all they ever needed.
2026-09-12 23:55:22 +02:00
Maurice 105bba2b95 docs(wiki): describe the Dawarich integration
What it reads, what it never writes, what happens to the recorded
positions, and how the connection, the suggestions, the Atlas marks and
the wish list behave from the reader's side.
2026-09-12 23:37:13 +02:00
Maurice 5b4a7e82ff feat(client): show Dawarich stays, routes and marks where they belong
The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
2026-09-12 23:37:05 +02:00
Maurice 5678eed55c fix(client): use TREK's own controls for tooltips and journey dates
Three small things noticed while working nearby, none of them Dawarich:

The map's layer switcher showed the browser's own tooltip while every
control beside it showed TREK's, so hovering along the row produced two
different boxes in two different styles.

The journey entry editor mixed TREK's date picker with a native time
input, and the date, which is the longer of the two, had the narrower
column: "12. Sept. 2026" wrapped while the clock sat half empty. The row
is split in the date's favour now and the picker is disabled together
with the rest of the form instead of staying live behind it.

On the phone's journey, the dock's "+" now turns into an upload button
while the Gallery tab is open, which is what that tab's one big action
actually is.
2026-09-12 23:36:49 +02:00
Maurice 218d8baf0a feat(server): read visits and recorded routes from Dawarich
A new `integrations/dawarich` domain behind its own addon. Each reader
connects their own instance; the API key is encrypted at rest and never
returned. Nothing is written back to Dawarich, and no raw position is
kept in TREK: the recorded route is fetched per request for the window
being looked at and thrown away again.

What it produces are suggestions, never facts. A recorded stay becomes a
proposed place or journal entry, a country or city becomes a proposed
Atlas mark, and a stay near a wish becomes a proposed tick-off; each one
waits for the reader to confirm it. Identity is (user, Dawarich visit),
so a second sync neither duplicates nor overwrites what was accepted,
and deleting the result puts the suggestion back on the list.

The sync runs as a job on the central scheduler, one user at a time,
guarded against overlapping runs.
2026-09-12 23:36:49 +02:00
Maurice dad1316172 feat(api): let public API keys be scoped to single areas
A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.

Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
2026-09-12 23:36:09 +02:00
Maurice 906eef4349 feat(shared): add Dawarich contracts
Zod schemas for the Dawarich integration: connection settings, the
suggestion list and its accept payloads, the recorded-track window and
the Atlas country/city proposals. Places gain an optional `source` so a
place that came out of a recording can say so.
2026-09-12 23:36:08 +02:00
Maurice b010305c0d Merge pull request #2348 from liketrek/fix/collab-links-polish
feat(collab): make the links panel look like the rest of the collab tabs
2026-09-12 19:44:20 +02:00
Maurice 3529b72393 feat(collab): give links the header and empty state its siblings have
The tab was built to its own pattern: a bold title where the other four
carry a small uppercase one, a counter instead of the add button, the add
form as a light block sitting on top of the list, and a grey line of text
where every other empty panel shows the mascot. Side by side with Notes it
read as a different app.

It now uses the poll panel's shell: the same header, the same pill button
top right, the same modal for the form, and the mascot holding a chain.

The load effect depended on the toast and translation objects, both of
which are new on every render, so the list refetched constantly and a link
that had just been added was dropped again by the response in flight. It
depends on the trip alone now, and reads the other two through refs.
2026-09-12 19:37:07 +02:00
Maurice dd5b45ffda feat(collab): a chain for the mascot to hold on an empty links panel 2026-09-12 19:37:07 +02:00
Wenyin Wei ef259e7d7d fix(shared): keep the header's decoration from widening the page
The two decorative circles in the header are positioned outside it on purpose
(-40 to -60 on each axis) and nothing in the ancestor chain clips them, so the
first one widened the document by its own overhang: a 390px phone reported a
450px scrollWidth and the whole page could be dragged sideways (#2345).

overflow: hidden on the header is what the decoration always meant.
2026-09-13 01:13:34 +08:00
Wenyin Wei 12eabbb685 fix(shared): cluster the public share page's markers
The share page drew every stop as its own pin while the planner drew the same
trip as a handful of clusters, so a shared link opened on three dozen
overlapping markers. On a 300px-tall map that covers a whole city, the pins
underneath can be neither read nor tapped (#2343).

The option block and the icon factory move to components/Map/markerCluster.ts,
which both maps import, so the two cannot drift apart again. The planner's
options are unchanged.

leaflet.markercluster's two stylesheets were imported by MapView.tsx alone.
The share page never mounts MapView, so it imports them itself: without them
the bubbles keep the library's default styling and spiderfy's legs are
invisible.
2026-09-13 01:11:14 +08:00
Maurice 11b69ae815 test(places): give the hand-built places tables the Amap id column 2026-09-12 18:07:39 +02:00
Maurice 79095823e5 chore(maps): drop the attribution import the tile helper replaced 2026-09-12 18:03:11 +02:00
Maurice f2bd116044 docs(maps): an Amap link needs a coordinate
A bare amap.com/place page carries an id and nothing else, and turning that
id into a location would need a keyed detail lookup the URL resolver has no
user for. It answers 400, which AMAP-084 pins. The wiki offered the link as
paste-able and the parseAmapUrl comment still promised the lookup, so both
now say what the code does.

The provider header also claimed photoRefs/photoBytes were deliberately
left out, naming two methods the places-provider interface no longer has.
The reason is unchanged: an Amap picture comes without a licence statement
we could show, so an Amap place gets its picture from Wikimedia.
2026-09-12 18:03:11 +02:00
Maurice ba535e5aab fix(maps): ask Amap only where Amap can answer
With Amap in the keyed slot every reverse lookup went to Amap first,
worldwide. Outside its box that buys a round trip and an empty answer
before Nominatim is asked anyway, which is latency on every right click in
Lisbon. The client side of the same question already filters by the box
(placeNavigation.ts); isOutsideChina is the shared helper both use.

The two fixtures in the file that were cast to any now carry a type: the
key row and the fetch spy whose calls the reverse case reads back.
2026-09-12 18:03:11 +02:00
Maurice aa0d62f063 fix(admin): read key presence from the app config, not the form
The place search provider block now renders on a managed install too, and
there the key fields are empty by design: the operator owns the credentials
and user-profile never sends them. An operator key set through the
environment is invisible in the fields either way. The warning behind the
provider select read those fields, so it told every managed admin that
search "falls back to OpenStreetMap" while their key was answering.

app-config already carries the answer as has_maps_key, and it resolves the
same chain the search itself uses, environment included. has_amap_key sits
beside it, so both come into the store at boot and both follow a save.
2026-09-12 18:03:11 +02:00
Maurice 3e8ab22836 docs(maps): Amap beside the TREK index
The index and OpenStreetMap answer every search; the provider choice
only decides who fills the keyed slot next to them. Rows carry a source
mark, a place keeps its Amap id, and the journey map shares the shifted
projection.
2026-09-12 18:03:11 +02:00
Maurice 2332d45d01 i18n(maps): Amap strings for Catalan, Swedish and Vietnamese 2026-09-12 18:03:11 +02:00
Maurice 2fd5b6b00a fix(admin): keep the place search provider reachable on a managed install
The API keys card was hidden wholesale on a managed install, which took
the provider choice with it although the server leaves places_provider
to the admin there. The card stays; the keys, their Save button and the
Google switches fold away, on the phone shell now too. The mobile
warning uses the pending token instead of an undefined one.

The desktop tests find a key input through its labelled toggle rather
than by position, which is where the accessible name sits.
2026-09-12 18:03:11 +02:00
Maurice f120266f9e fix(pwa): cache Amap tiles like the other raster presets
Without a Workbox rule for is.autonavi.com the offline pre-download
fetched the tiles and stored none of them, the hole #2180 closed for the
OpenStreetMap DE and Stadia presets.
2026-09-12 18:03:10 +02:00
Maurice fdb993de62 fix(maps): project the journey map like the planner on a GCJ-02 basemap
The journey map read the same tile setting as the planner but built its
Leaflet map without the shifted CRS, so an Amap basemap put every entry a
few hundred metres off. A change of datum rebuilds the map, since Leaflet
cannot swap a CRS after construction; every other template change still
retiles in place.
2026-09-12 18:03:10 +02:00
Maurice 84c277993c feat(maps): show where an Amap hit came from, and keep its id
An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.

amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
2026-09-12 18:03:10 +02:00
Maurice 43a6c30325 feat(places): remember the Amap id a place was found by
The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
2026-09-12 18:03:10 +02:00
Maurice 1944bdf0a5 feat(maps): let Amap take the keyed provider slot beside the TREK index
The rebase kept maps.service.ts as dev has it: the TREK index and
OpenStreetMap answer every search first, and a keyed provider only fills
what they leave empty. Amap now sits in that slot next to Google instead
of behind an extracted Google provider, which is dropped.

places_provider (auto | google | amap | openstreetmap) decides which keyed
provider holds the slot; auto keeps whatever the install already used.
An amap: id opens against the Amap key regardless of the setting, takes
the plain details lookup on expand, never reaches Google's photo call,
and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding
asks Amap at the instance key before Nominatim.

The migration carries users.amap_api_key, which the rebase had left at a
stale array position, and places.amap_poi_id for the next commit.
2026-09-12 18:03:10 +02:00
A1phaN 57eac32f9f feat(map): amap as map api provider and display template 2026-09-12 18:02:57 +02:00
Maurice a066c94ec5 fix(costs): tell the phone when the settlement did not load
The mobile Costs tab swallowed a failed settlement request, and the final
budget card then filled every traveler's row from the empty fallback: "0.00"
for everyone, which reads as "the trip cost nobody anything". The desktop
panel already keeps a settlementError flag and prints its notice instead of a
number; the mobile tab now does the same in the final budget card.
2026-09-12 17:33:15 +02:00
Maurice 9307025ed0 fix(costs): derive the final budget breakdown on the server, in the same cents
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.

The settlement response now carries the rows with the figure: `sources` on each
finalBudgets entry lists what the person fronted per expense, the recorded
transfers on their side and the open flows, in display cents. They are filled
by the same two loops that fill the totals, so an expense with no split members
stays out of them as it stays out of the ledger, and each list is spread over its
figure's already allocated cents with the same largest-remainder split, so the
sum is exact in every display currency. The shells print those cents and only
resolve names; the rows carry the sign of the line they sit under.
2026-09-12 17:33:15 +02:00
Maurice 66761d57bb i18n(costs): name the reimbursement line for both sides
"Reimbursements received" was written from the receiver's chair: the traveler who sent a payment saw it as a plus under a label claiming they had received something. The line is net of both directions, so its label now says so in every locale.
2026-09-12 17:33:15 +02:00
RemEx e0c9a8d1b6 feat(costs): show what the trip finally costs each participant
The settle-up view says who owes whom, but nothing on the Costs screen
answers "what did the trip cost me": the balances move every time a
payment is recorded, and the tiles only describe the signed-in user.

The settlement response now carries finalBudgets, one row per participant
in the ledger: expenses (what they fronted on split expenses), reimbursed
(recorded transfers, received minus sent), pending (their current
balance) and final = expenses - reimbursed - pending.

- Computed in calculateSettlement from the same integer trip cents as the
  balances, filled by the same loops, so the breakdown can never
  contradict the balance printed next to it. Each component is converted
  to the display currency as its own set and the final is subtracted in
  display cents, so the three lines add up to the total in any currency.
- A "Final budget" card on desktop, the narrow desktop layout and the
  mobile Costs tab: one amount per traveler, and a tap opens the three
  lines plus the expenses, transfers and open flows behind them. The
  filtering lives in CostsPanel.helpers so both shells share it.
- Contract in shared (budgetParticipantFinalSchema); the MCP
  get_settlement_summary description mentions the new field it returns.
- Four new costs.* keys, translated in every locale.
2026-09-12 17:33:15 +02:00
Maurice a635e31bd4 test(db): pin the roadtrip preferences replay to its own schema version 2026-09-12 17:33:04 +02:00
Maurice bc6d804af6 docs(budget): say what settled_at does when it is left out 2026-09-12 17:33:04 +02:00
Maurice 259a8c6b4d test(budget): settled_at through the HTTP pipe
The e2e settlement round trip now sends settled_at on create and update
and reads it back from the column, including a PUT that clears it to
NULL. The service test covers the clear branch for null and the empty
string the date picker sends.
2026-09-12 17:33:04 +02:00
Maurice 953720c874 fix(client): clamp a flipped date picker to the viewport
Anchoring the flipped popup to the trigger's top edge dropped the old
vertical clamp: with a short viewport the popup's top edge left the screen
and the month header became unreachable. The popup now flips up only when
there is room above; otherwise it stays below and is clamped so its top
edge never leaves the viewport.
2026-09-12 17:33:04 +02:00
Maurice aa848cc6b1 fix(budget): keep a payment's currency when it is edited on a phone
The mobile payment sheet always sent the display currency, while it seeded
the amount raw in the currency the payment was recorded in. Correcting only
the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX
rate for it. The sheet now carries its own currency, seeded from the
payment (or the display currency for a new one) with the same picker the
expense sheet has, and the payment row shows the recorded amount next to
the converted one when they differ, like the desktop row does.

The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
2026-09-12 17:33:04 +02:00
RemEx 3dd6674d3f feat(budget): let a recorded payment be edited from mobile Costs too
Editing/undoing a settle-up payment was desktop-only — the mobile ledger
row rendered it read-only, with a comment explaining the gap was
deliberate for now. Give it the same edit/undo affordance the expense row
already has: edit reopens AddPaymentSheet pre-filled (now doubling as an
edit form, same as desktop's SettlementModal), undo deletes it outright
with no confirmation, mirroring CostsPanel.tsx's undoSettlement.
2026-09-12 17:33:04 +02:00
RemEx bef11c5222 fix(client): keep a flipped date-picker popup anchored to its trigger
CustomDatePicker flipped above a trigger by subtracting a fixed worst-case
height (360) from its top, regardless of the popup's real rendered height.
When that guess overshot — a short calendar view, or a form with little
room, like the settle-up modal — the gap left the popup floating away from
its trigger and over unrelated fields instead of sitting right above it
(#2334, found while testing the settle-up date field).

Anchor the flipped case from the trigger's own top edge via `bottom`
instead, the same technique useAnchoredPosition already uses for other
popovers, so the popup is always flush against its trigger no matter its
actual height.
2026-09-12 17:33:04 +02:00
RemEx 3a8ae6334c test(budget): match applySettlementUpdate's new settled_at SQL args
The unit test mocking db.run for applySettlementUpdate asserted the exact
positional argument list, which the settled_at column added two more
positions to (its CASE-WHEN flag and value, mirroring currency/exchange_rate).
2026-09-12 17:33:04 +02:00
RemEx bd46089450 feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
2026-09-12 17:33:03 +02:00
Maurice 76e182f44e sort route avoidance classes explicitly 2026-09-12 17:26:38 +02:00
Maurice 51626e8184 make roadtrip availability checks explicit 2026-09-12 16:46:22 +02:00
Maurice bc04072dc9 keep overnight checkout planning after a later arrival 2026-09-12 16:22:11 +02:00
Maurice bc15036cfb cover roadtrip MCP authorization and search filters 2026-09-12 16:07:36 +02:00
Maurice 0c78462567 show only planned places on the roadtrip map 2026-09-12 16:05:02 +02:00
Maurice 4f1d0aa210 adjust map clustering and refresh system sqlite 2026-09-12 16:02:01 +02:00
Maurice 465f67ae05 Merge pull request #2334 from liketrek/codex/roadtrip-day-boundaries
Add configurable Roadtrip day endings and station clusters
2026-09-12 15:52:25 +02:00
Maurice 8b99188fc8 fix roadtrip regression test fixtures 2026-09-12 15:47:57 +02:00
Maurice ac57321840 merge dev and reconcile roadtrip planning 2026-09-12 15:45:34 +02:00
Maurice b10b02c685 finish overnight stops and cross-day range warnings 2026-09-12 15:37:09 +02:00
Konstantinos Thermos 359551653f Merge pull request #2319 from liketrek/feat/trip-route-overview
feat(maps): draw every travel day's route at once, with the trip total
2026-09-12 14:49:44 +03:00
Maurice f56e7a161e add open charging data and polish roadtrip warnings 2026-09-12 13:25:15 +02:00
Maurice 228e6a13a9 fix(admin): say what the local model actually gets 2026-09-12 13:07:08 +02:00
RemEx b58771fd4c fix(admin): recommend the local model that also reads images
The one model the addon offers to pull was Qwen3 8B, which is text-only, so a
scanned page or a photographed document reached it as no text at all and came
back empty. Qwen3.5 4B is multimodal at a third of the size: one download
covers every kind of document instead of leaving the operator to work out that
the recommended model cannot read half of them.

The list stays one entry long. A self-hosted host needs a model, not a menu,
and any other id can still be typed in or picked from what Ollama already has.
2026-09-12 13:07:08 +02:00
Maurice b7ed11ef4a Merge pull request #2335 from harl00/patch-1
Clarify settings path for creating machine client
2026-09-12 13:03:51 +02:00
Maurice b90d258b6c docs(mcp): say whose settings the OAuth client lives in 2026-09-12 12:55:34 +02:00
Luffy e8431d6426 fix: close file picker on outside click and modal dismiss 2026-09-12 12:55:01 +02:00
Maurice 41b18f970c feat(vacay): add manual school holiday calendars 2026-09-12 12:51:27 +02:00
Maurice ff5fa55fad add Google Maps route import and plugin corridor search 2026-09-12 12:24:33 +02:00
Maurice af45b0ca74 add roadtrip warning areas and tidy driving settings 2026-09-12 11:23:09 +02:00
amo c0e3fde6f7 Clarify settings path for creating machine client
Updated instructions to specify 'user' in settings path. Without it, minor confusion to be still operating in main 'Admin' settings where you enabled MCP integration
2026-09-12 13:08:18 +10:00
Maurice ec281e5974 fix(roadtrip): shorten day-ending settings 2026-09-12 00:34:12 +02:00
Maurice b773faff70 feat(roadtrip): share trip preferences and improve service stops 2026-09-12 00:24:45 +02:00
Maurice dd6ad31ab9 fix(roadtrip): keep day-ending labels draggable after icon changes 2026-09-11 23:30:38 +02:00
Maurice fccc55732b feat(roadtrip): plan and edit trips through MCP without a browser 2026-09-11 23:25:06 +02:00
Maurice 491cfccc56 feat(roadtrip): add daily scheduling and configurable day endings 2026-09-11 22:28:03 +02:00
Konstantinos Thermos 3c735c80a4 Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview
# Conflicts:
#	client/src/components/Map/MapView.tsx
#	client/src/components/Map/MapViewGL.tsx
#	client/src/pages/TripPlannerPage.tsx
#	client/src/pages/tripPlanner/useTripPlanner.ts
2026-09-11 16:22:21 +03:00
Maurice 4f8605b529 merge dependency maintenance #2300 into dev 2026-09-11 15:21:10 +02:00
Maurice 50839f8cc1 copy the map sanitizer patch before Docker installs dependencies 2026-09-11 15:02:06 +02:00
Maurice 8ee2b6ae4c merge dependency maintenance #2322 into dev 2026-09-11 15:01:15 +02:00
Maurice 3e37a708c7 merge dependency maintenance #2324 into dev 2026-09-11 15:01:01 +02:00
Maurice d8f2dc755d merge dependency maintenance #2323 into dev 2026-09-11 15:00:47 +02:00
Maurice 134fcbb424 merge dependency maintenance #2314 into dev 2026-09-11 15:00:35 +02:00
Maurice 2c06c9904b feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.

What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.

What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.

A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.

An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
2026-09-11 14:58:34 +02:00
Maurice 67dd43bc8a fix(maps): backport attribution sanitizer fix without dropping WebGL1 2026-09-11 14:55:37 +02:00
Maurice f5ef14a05f build(deps-dev): update vitest and coverage together 2026-09-11 14:35:51 +02:00
Maurice 7eba81dfd8 build(deps-dev): update sharp with its musl bindings 2026-09-11 14:35:50 +02:00
Maurice edb9a55099 build(deps-dev): refresh baseline browser data 2026-09-11 14:35:50 +02:00
Maurice 74a0825f5a build(deps): update hono to 4.13.7 2026-09-11 14:34:33 +02:00
Maurice a8e4c45576 feat(share): show a stop and a booking in full on a shared link, and no more (#2320)
A public link showed a stop as its name and one line under it, the address
or the description, whichever came first. The owner had written both, and
a note on the place and another on the day, and had set how long they meant
to stay and how to reach it. All of that shows now, laid out the way the
planner lays it out, and every link leaves the page in a new tab with no
opener. A booking shows the note the owner attached and the page they
linked, labelled by where it goes.

What travels to the page is chosen column by column now, where it used to
be the whole row. The whole row of a booking carried its confirmation
number, its import trail and its raw metadata, and the metadata is where an
imported ticket keeps its seat and its record locator; the whole row of a
place carried the owner's booking notes on it and the Google identifiers.
None of that belongs to somebody holding a link. Metadata keeps the parts
that describe the journey, by an allow-list, so a field the import starts
writing tomorrow does not become public by appearing. Links are accepted
only as http or https, on the server and again on the page.
2026-09-11 11:01:25 +02:00
Maurice 87c73e50fb feat(studio): give a book its first and its last page (#2317)
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.

The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
2026-09-11 11:01:15 +02:00
Maurice 1128bcd1c5 feat(journey): let the photo picker's calendar jump years (#2318)
The date range on the photo picker had a calendar that moved one month per
click and no other way to move. A trip three years back was thirty-six
clicks away. The header is a button now, the way the app's other calendar
works: click the month to see the year's months, click the year to see a
page of twelve years, and the arrows step whichever level is showing. A
pick steps back down, and reopening starts on the days again. The same
component serves the entry editor, which keeps its trip-day dots.
2026-09-11 11:01:04 +02:00
Maurice 7946c2f749 fix(maps): measure a booking line along the road before hiding it (#2275)
A short transport hop is hidden once its two ends sit closer than a few
dozen pixels, so a booking that would draw as a dot does not clutter the
map. That check compared the endpoints, which was the whole line while
every hop was straight. A car booking is routed along the road now, and a
road that leaves a town, rounds a lake and comes back to the next town along
can run across half the screen while its ends project sixty pixels apart.
The whole drive vanished until the map was zoomed far enough in for the
ends to separate, which is the gap in the route the report shows.

The decision now walks the line that will actually be drawn, road route or
arcs, and keeps the hop as soon as that reaches the floor. A straight
two-point hop measures exactly what it measured before, so every existing
threshold holds for the case it was written for. One helper serves both
renderers, which also folds the two hand-copied floor tables into it.
2026-09-11 11:00:57 +02:00
Maurice 5ef60f5641 fix(todo): describe the MCP priority the way the app reads it (#2297)
The create_todo and update_todo tools said 1 was low and 3 was high. The
app, its sort and the wiki all say the opposite: 1 is P1, the red one at the
top of the list. An assistant that followed the description filed every
urgent task as the least urgent. The words now match the badges, and the
update tool, which said nothing at all, says the same and that null clears
it. No stored value changes.
2026-09-11 11:00:45 +02:00
Maurice 9ce8b7ea9e test(roadtrip): cover the fold, the seam and the live via, and fix what that found
Cases appended to four existing suites for the lines this branch left unrun: the
fold state and the map places it hides, the owner-based via anchor, the day seam
and the proportional geometry slicing, and the live roadtripVia:changed events.
With the four new suites in the commits before this one, coverage of the lines
this branch adds goes from 69 % to 96 %, measured the way the gate measures it —
the routed lines against the diff, not a file average.

Every added case was mutation-checked: the rule it claims to pin was broken in
the source and the case had to fail. Two of them found real defects rather than
missing coverage, and both are fixed here.

A via dropped on the incoming night drive went to the wrong leg. The guard for
it asked the insert index whether the point lay before the card's first stop,
and that index cannot answer: insertIndexForAlong clamps to at least 1 for any
card with two stops or more, and the rail publishes no other kind. So the branch
read correctly and never ran, and a via on the night stretch was filed after the
first drawn stop — the route ran forward, doubled back to the point and carried
on, which is exactly what the comment above it said must not happen. Asked of
the distance instead.

The two one-field writes the rail offers, the stop kind and the fill level, went
straight to the API while their neighbour checked place_edit first. The server
refused them, so nothing was ever saved, but a reader got an error toast for
touching a control that should not have acted.
2026-09-10 21:40:51 +02:00
Maurice a9907e2f10 feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
2026-09-10 21:40:51 +02:00
Maurice a00cc889ed feat(roadtrip): show every change to a road trip live to everyone on it
Two people shaping one drive were each editing their own copy: a via dragged on
one screen reached the other only on the next reload, and the route it changed
looked settled until then. Every write in the domain now announces itself over
the websocket the rest of the trip already uses — the five REST routes and the
matching MCP tools alike, so an assistant moving a stop is as visible as a
person doing it.

Two new trip-scoped events carry it: roadtripVia:changed with the day's whole
via list, and roadtripTrack:changed. The whole list rather than a delta, because
a via is identified by its position in a chain and a delta protocol would need
an ordering guarantee to be worth anything.

The originating socket is excluded through the X-Socket-Id header the other
domains already forward, so nobody watches their own change arrive twice.
2026-09-10 21:40:51 +02:00
Maurice 3f0eb210d8 feat(roadtrip): move a stop reached after midnight onto the day it is reached
A day that runs past midnight used to keep every one of its stops on its own
card, so day one ended at 03:12 with three places nobody reaches on day one and
day two began with a drive that had already happened. The rail now lays the
stops out by the date they are actually reached: when the clock goes backwards
between two arrivals the ones after it move to the following day, into a block
that says where they came from, and the night drive and its kilometres go with
them. It is an arrangement, not a write — the day planner beside it is
untouched, nothing reaches the database, and a shorter stay the evening before
puts everything back on its own.

The map follows the same arrangement, because a boundary drawn in the rail and
not on the map is two answers to one question. Day one now ends where its last
reachable stop is and the line, the legs and the totals are cut to match.

Two switches in the driving settings, off by default. "Connect the days" routes
the gap between one day's last stop and the next day's first, which is real
driving the rail has never drawn, and counts it. "A colour per day" then puts
the days back into a line that has become continuous, and tints each day header
to match.

Collapsing a day from its header takes its route and its places off the map as
well as out of the rail, and the categories in the corridor panel fold the same
way. The refuel band is one full-width red band with the pump as its button,
the results are chips, and the driving dialog is two columns of panels with the
figures on one right edge.

Also fixes a via that would not adjust the route once dragged. Five separate
causes, all measured rather than guessed: leg threading used the card index
instead of the owning day's, the anchor lookup returned chain indices, it
filtered by the card rather than by the anchor's owner, seams were routed
without their vias, and a seam once routed was never re-fetched.
2026-09-10 21:40:51 +02:00
Maurice bd6e6312e1 fix(roadtrip): keep a typed range when the parts round away to nothing
A consumption typed as 500 rather than 5.0 makes the tank arithmetic round to a
range of zero. Zero is a number, so the `??` in effectiveRangeKm took it as the
answer and discarded the range the traveller had typed by hand next to it, and
useVehicleRange then folded that zero to null. What reached the rail was "no
range at all": every fuel and charge warning went quiet, with nothing on screen
naming the field that did it.

A range that rounds below one kilometre is a typo rather than a car, so it is
reported as no answer and the typed figure gets its turn.
2026-09-10 21:40:51 +02:00
Maurice 96ab748f34 feat(roadtrip): let one stop say how full it fills
The fill level was one figure for the whole trip, and a trip does not have one.
A motorway rapid charger is worth about 80 % because the last fifth costs as
long again; the charger at the hotel is worth all of it because the car stands
there all night. Between those two the difference is a whole leg, and a single
setting has to be wrong about one of them.

So the badge on a refuelling stop is now the way in: it shows what that stop
will actually fill to, its own figure or the traveller's, and opens a panel of
the six answers people actually give. Under them it says what the choice buys in
kilometres, because 80 % is an abstraction and "about 290 km from here" is the
thing being decided. Picking the figure a stop already has hands it back to the
setting, the same gesture the kind picker uses to un-pick a kind.

The value lives in places.fill_percent, beside stop_type, because it is a
property of the stop rather than of the person. Null means "follow the setting",
which is every stop that predates the column and every stop nobody has an
opinion about, so nothing changes for a trip that never touches it.

Two pieces of shared machinery came out of it rather than being copied. The
popover chrome — measure before showing, flip when the window is short, close on
an outside click, Escape or a scroll — is one component now, used by the kind
picker and this one. So is the assembly of the range from seven settings, which
the routes hook and the rail both need and which would have drifted the first
time a field was added.
2026-09-10 21:40:51 +02:00
Maurice aaf6559d5d feat(roadtrip): state a range as the car it comes from
A range could only be typed as one number, and that is the number fewest
travellers actually know. A tank size is on the filler flap, a consumption is in
the manual, a battery capacity and a kWh per hundred are on the data sheet. All
of them divide into the same figure, so the dialog now takes either and does the
division itself. What the parts say wins over what was typed, because it is the
more specific answer, and the range row goes read-only when they do so there is
never a second answer to the same question.

Of ABRP's list only battery wear is here. Plug type, reference speed, relative
speed and drive style shape a consumption PREDICTION; this addon does not
predict one, it divides a capacity by a consumption the traveller states, so
those fields would add typing without moving a kilometre. Wear is different in
kind: it shrinks the capacity itself, and on an older car by enough to decide
whether the last leg of a day arrives.

Storage stays metric throughout, the same rule the range itself follows. An
imperial traveller types gallons, miles per gallon and kWh per 100 miles and
gets them back unchanged; mpg is the inverse of litres per hundred rather than a
scale factor, which a straight multiplication would have stored as a car four
times as thirsty.

The gauge under the picker is the argument that these two ways of stating a
range are the same thing, so it answers while the figures are still being typed
rather than after the field is left. It draws the tank the day it left the
factory: the filled part is what a stop actually puts back in, the hatched tail
is what age has taken off, and both are things the bare number cannot say.

Also here, from the same round: a fill-up that stops short of full. Nobody
charges to 100 % on the road, and counting every stop as a full tank overstated
everything after it by that last fifth. Stops that refuel now carry a badge
saying how far they fill.
2026-09-10 21:40:51 +02:00
Maurice 5b147af0b6 feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band
Four things that only showed up once it was on screen.

The range budget treated a petrol station and a charger as the same thing, so an electric
car pausing for coffee at a filling station had its battery refilled on paper: the
warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can
now be named, and then only what actually refills it counts — and the search asks for that
kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the
old behaviour, which is what everybody who never opens the dialog is on.

The band was its own shape in a rail made of drive bands. It is one now: the same rail,
the same height, the same type size, a warning tint instead of a ring, and the figures
where a drive band keeps them. The buttons are icons of the same 18px square the shuffle
mark uses, so the band stays one line beside its neighbours.

The old range warning stood down where the band shows. Both at once said the same thing
twice in the wrong order — the answer above the problem — because the warning marks the
stop somebody finds out at while the band sits on the leg the fuel ends on.

Two measured corrections to the search itself. The server turns a box into a circle of
half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35
and came back flagged as narrowed EVERY time, which read on screen as "we could not
check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before
the dry point rather than on it, because only road already driven is any use — centred on
the point, half the circle was spent on road nobody can reach.

An answer that finds nothing now leaves the button rather than only a close. The place
search is a shared public service that does time out, and "it did not answer" with no way
to ask again reads as broken rather than as busy.
2026-09-10 21:40:51 +02:00
Maurice 95d1752a3d feat(roadtrip): suggest where to fill up, not just warn that you cannot
The range budget has always been able to say "you have driven 245 km on this tank" and
never "and here is where to do something about it". The review put it plainly: the value
is in the suggestion, not in the warning.

The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The
fuel runs out earlier, often most of a leg earlier, and a filling station offered at the
warning is one the car cannot reach. So the dry point is computed where it actually is,
a search runs around it on request, and what comes back is filtered to what can be
reached.

Three filters, each one there because its absence gives a wrong answer rather than an
untidy one. Only what lies before the tank empties, because a pump past it is on the far
side of an empty tank. The detour counts double, because 8 km off the road is 16 km of
driving. And a reserve is kept back, because the range figure is a round number from a
manual, not a measurement.

Sorted by the detour rather than by how far it gets. Measured on a real day between
Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one
kilometre more of range, while one 0.9 km off sat almost as late.

Deduplicated by name and half a kilometre, which also came out of that measurement. OSM
maps a charging site as one node per socket: the first three offers were the same
"autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m
apart with 450 m between the pairs, while genuinely different branches of one chain were
18 to 36 km apart — so anything from 500 m upwards separates the two cases.

An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after
a request that failed or was cut short states something that was never checked, and the
server flags both cases itself. Offline it does not offer at all: the place write queues
offline but the day assignment does not, so accepting one would leave an orphan.

Nothing is searched until it is asked for, and one press is one request against a small
circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to
the same popup a corridor hit goes through, so the stop kind, the dwell time, the
position, the via re-anchoring and the re-route are the paths that already work.

The dry point counts DRIVING metres only. The day's drawn line concatenates every run,
ferries and walks included, while the budget skips those, so walking that line to the
same figure would overshoot by the length of any crossing and put the marker out at sea.
2026-09-10 21:40:51 +02:00
Maurice c9e0fe52d5 wip(roadtrip): the point where the tank runs dry, as a place
deriveDriveWarnings knew the distance and threw the position away. The warning is filed
against the stop the car ARRIVES at, which can be most of a leg past the point the fuel
actually ran out — fine for a warning, useless for a suggestion, because a filling
station offered at the warning is one the car cannot reach.

emptyAt is a fourth field on the return object rather than a field on each warning, for
two reasons. Five tests assert warnings with toEqual and an extra defined property fails
them all. And a dry point is once per TANK while a warning is once per stop: a long
stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning
would stack three identical offers for one fill-up down a single day.

drivenMeters counts driving legs only, which is the whole reason it is counted here
rather than derived later. The day's drawn line concatenates every run, ferries and walks
included, while the budget skips those — so walking that line to the same figure
overshoots by the length of any crossing the day contains and puts the marker out at sea.
pointAtMeters walks the driving-only line instead.
2026-09-10 21:40:51 +02:00
Konstantinos Thermos bf1900c3a0 feat(maps): draw every travel day's route at once, with the trip total
Extract the day route builder out of useRouteCalculation into a pure module and
aggregate it over the whole trip: one map with every day's route, each day in its
own colour, the mode per stage and the trip's total distance. Straight lines first,
roads as they answer; a refused leg keeps its line and adds no distance.

Give the floating map controls one shared drop shadow while adding the toggle, so
the new pill and the base-layer switcher stand off the tiles the way the locate
button always has. Document the control in wiki/Map-Features.md.

Addresses #1736
2026-09-10 15:57:48 +03:00
Maurice 59b7c35e87 fix(roadtrip): re-pin a via to the leg it was dragged onto
Dragging a via somewhere else on the map changed where the point IS and never where it
sits in the chain. The anchor that decides which two stops it is threaded between was
worked out once, when the point was dropped, and the drag sent only coordinates —
`roadtripViaUpdateRequestSchema` carried a lat and a lng and nothing else.

So a via pulled past the stop it used to precede kept claiming the earlier leg. The
router then drove out to it and turned back before carrying on, which on screen is not a
subtly wrong route: it is a spur that reads as the drag having done nothing at all, with
the original road still sitting there unchanged.

The anchor is not a property of the point but of where the point sits along the drive, so
it is recomputed on a drag the same way it is on a drop. The projection that did that was
inline in addRoadtripVia; it moves to a shared `anchorFor` and both callers use it.

A drag differs from a drop in two ways, both deliberate. It measures against its OWN day:
letting the nearest day win, which is right for a click on open map, would hand the via to
a neighbouring day whose road happens to pass closer and make it disappear from the day it
was dragged in. And it has no 2 km guard: pulling a via well off the current road is
exactly what the gesture is for, and the anchor only says which leg gets bent.

`after_order_index` is optional on the way in, so a drag inside one leg and an older
client still send just the coordinates, and the stored pin is then left alone rather
than cleared.
2026-09-09 19:03:00 +02:00
Maurice 77162aa8d5 feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.

FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.

Four things the docs would have got wrong, all measured against the live instance:

- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
  and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
  shape drags every intersection along. So the adapter translates instead of borrowing a
  familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
  snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
  radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
  it is split into runs overlapping by one point, which is exact rather than approximate:
  a measured six-stop day came back within two metres and under a second of the same day
  asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
  those times feed the arrival clocks of a day. So a day is answered by one engine or by
  neither, never by both, and a piece that will not answer loses the whole day rather than
  half of it — the schedule chains leg times blindly and would otherwise print a complete,
  plausible, silently wrong timetable.

Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.

Two things that would have broken quietly and did not:

- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
  studio and the booking geometry. Without the avoidance in the key, flipping a switch
  returns the old road out of cache in under a millisecond — and worse, an avoiding answer
  stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
  echoes the coordinates it was given and never says where it bound them, but the ends of
  a leg's line are exactly those points: measured against Füssen, its first vertex matches
  OSRM's snapped location to the digit.

The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.

Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
2026-09-09 19:03:00 +02:00
Maurice 29fc586930 test(maps): pin the coordinates a suggestion carries
The half of the change that saves the pick from a text search for its own
label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there;
the client test writes the coordinates into its own mock and then asserts them.
Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the
fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎"
and taking whatever comes back first.

So the server test now asserts the coordinates on the wire for both indexes,
and the mobile sheet — which carries a copy of the same fallback and had no
test for it at all — gets one for the fallback and one for the source mark.
2026-09-09 18:55:31 +02:00
Maurice 7ba7338f5f fix(maps): sag bei jedem Vorschlag, welcher Index geantwortet hat
Seit die Tipp-Suche beide Schichten fragt, ist ihre Liste gemischt — die
Antwort nennt aber weiterhin EINE Quelle fuer die ganze Liste, und die heisst
'trek-places'. Der Client beschriftet damit jede Zeile, auch die aus
OpenStreetMap. Wer tippt, liest also acht Mal "TREK" und schliesst daraus, die
OSM-Schicht antworte gar nicht. Sie antwortet, sie steht nur unter falschem
Namen da. Auf der Suchstrecke daneben stimmt es laengst: dort traegt jeder Ort
sein eigenes `source`, und genau deshalb liest sich diese Liste richtig.

Also traegt es jetzt auch die Zeile. Das Feld ist optional, weil Google und der
Nominatim-Rueckfall je aus einer Quelle antworten; eine Zeile ohne eigene
Angabe faellt weiter auf den Namen der Liste zurueck.

Dazu die Koordinaten, die der Vorschlag ohnehin schon kennt. Bisher wurden sie
weggeworfen, und wenn der Details-Aufruf nicht antwortete — Kill-Switch, ein
ueberlasteter Overpass-Spiegel, irgendein Fehler oben — baute der Client eine
Textsuche aus Name und zweiter Zeile. Bei einer OSM-Zeile ist die zweite Zeile
aber keine Adresse, sondern der ortsuebliche Name: gesucht wurde dann nach
"Tokio Hauptbahnhof, 東京駅丸の内駅舎", und dessen erstes Ergebnis wurde
kommentarlos als der Ort genommen, den der Nutzer angeklickt hatte. Jetzt
nimmt der Rueckfall den Punkt, den die Zeile mitbringt.

Und das Abzeichen gibt es endlich auch mobil. Die Marke und ihre Tabelle
liegen dafuer in client/src/utils/placeSource.ts, weil beide Oberflaechen
dieselben Listen zeigen und ein Zeichen, das nur auf einer davon steht,
schlimmer ist als keines.

Was hier NICHT drin ist, obwohl es dazugehoert: dass der richtige Bahnhof in
der Liste steht. Das liegt am Dienst, nicht an TREK, und ist dort gemessen und
behoben — Rangfolge nach Art des Ortes plus die Verkehrsknoten, die der
OSM-Schicht ganz fehlten.
2026-09-09 17:34:05 +02:00
Maurice 2f71549413 fix(maps): let the imagery through the CSP, so the GL switch does something
The satellite toggle has worked on Leaflet for as long as it has existed and did
nothing at all on a GL map, which is the default renderer since the vector basemap
landed. Not for want of the layer: connect-src never named server.arcgisonline.com.

Leaflet asks for a tile as an <img>, and img-src waves every https: host through,
so the gap could sit there unnoticed for as long as Leaflet was the only renderer
that showed the imagery. A GL map reads the raster through fetch to hand it to
WebGL, and that lands on connect-src instead, where the host is missing. The
browser refuses it in the document, which is the worst shape a failure can take
here: the switch flips, the layer goes on, the source is there, and no tile ever
arrives. Nothing in the app can see it either, because a CSP refusal is not an
error the fetch can catch.

The prefetcher asks for the same tiles the same way, with mode 'no-cors', which
relaxes CORS and not CSP, so the imagery was never cached for offline use either.

Same shape as #2180, which added the other two raster presets for exactly this
reason and did not reach this host.
2026-09-09 16:57:10 +02:00
Maurice fab3d2bdf8 fix(maps): ask the OpenStreetMap layer on the autocomplete path
Typing "Tokio station" from Tokyo suggested a weigh station in Ritzville and a
station in Tuxtla Gutiérrez, and typing "Tokyo Station" put Japan in seventh
place behind Markham, Arlington, Calgary, Milano and Melbourne. The station
itself was never in the list.

The service has had the answer all along. Its OpenStreetMap layer carries 14.6M
travel places with every name form each is known under, including the exonyms a
traveller actually types, and it is not the same data as the index: Overture
holds businesses, so a search for a station finds the shops inside it. The layer
returns "Tokio Hauptbahnhof" for the German spelling and 東京駅 for the Japanese.

It was never asked. `sources` defaults to the index alone and trekPlacesSearch
never sent the parameter, so no caller here has ever seen a layer row.

Sent on the autocomplete path only. The explicit search asks Nominatim in
parallel and would get the same OpenStreetMap places twice; this path asks
nobody else, because Nominatim's usage policy names autocomplete as
unacceptable use. So the layer is not a second opinion here, it is the only
place those names live.

A layer row has a different shape, and that shape is why this is more than one
parameter. It has no `gers` and no address block, and its id reads
`osm:node/9712313` where this file resolves `node:9712313`. Passing it through
untranslated would hand the client an id getPlaceDetails does not know, and the
failure would land after the user had already picked the suggestion. The
secondary line falls back to the local name, which is what is written on the
building and more use under a translated label than an empty line.

Measured against the service before the change, same query, same coordinates:

  "Tokio station"  index only  -> Tokio Weigh Station, Ritzville US
                   with layer  -> Tokio Hauptbahnhof in second place
  "東京駅"          index only  -> six restaurants inside the station
                   with layer  -> 東京駅 itself in the list
2026-09-09 16:06:19 +02:00
Maurice 00cac02665 feat(maps): satellite on the GL maps too, not just Leaflet
The imagery toggle has been there for a while and only ever worked on Leaflet, so
anyone on MapLibre or Mapbox — which is the default since the vector basemap landed —
had a switch they could not see and a view they could not reach.

Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a
style with dozens of layers in it. So the same tokenless ESRI source goes on as a
raster layer of its own, inserted before the first layer TREK draws, which leaves the
route, the pins and the tracks on top and legible. Switching back hides it rather than
tearing it down, so going to and fro does not re-fetch a tile.

Built on demand rather than once at load, because a basemap change rebuilds the style
from scratch and takes every source with it; the styledata event is subscribed for the
same reason. The switch reads the setting the Leaflet map already stores, so the choice
survives an instance changing renderer, and it sits in the same corner with the same
offsets.
2026-09-09 15:57:46 +02:00
Maurice 756e0fb908 fix(places): let the pool follow the open day, and say that it does
The map has narrowed to the selected day on the Planned filter since #2024. The pool
beside it did not, so a trip with 55 planned places read 55 in the tab and drew five
pins on the map, with nothing anywhere to say the two were answering different
questions. Reported twice in the same thread as the map being broken: you pick a day,
open Places, tap Planned, see everything you ever planned, and the map shows a handful
of it.

The list and the tab count now use the same set the map does. Unplanned deliberately
stays trip-wide: a place assigned to some other day is planned, whichever day happens
to be open.

A note under the tabs says so and closes the day when dismissed. It sits there rather
than over the map because a chip on the canvas is out of reach on a phone, which is
where this was reported from.
2026-09-09 15:50:47 +02:00
Maurice d30b916f9c feat(maps): count how much routing an instance actually does
Every route is calculated in the browser against the public FOSSGIS hosts, so the
server never sees a routing request and nobody has a number: not how many a real trip
costs, not how big they are, not how much already goes to a self-hosted engine.
Deciding whether TREK could run its own router without that number is guessing at the
bill, and the counters have to be collecting before the question comes up.

Counters, not a log. A row is a day, a profile, a request kind and an engine kind with
totals on it: how many requests, how many waypoints, roughly how far, how many were
refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the
instance: its own database holds them and its own admin reads them at
GET /api/route-usage/summary.

The client tallies in memory and flushes in batches — on a timer, past a threshold and
when the page is hidden — because a counter that cost a request of its own would
measure the load by adding to it. Routing all funnels through one fetch in
RouteCalculator, so that is the only place instrumented.

On by default and switchable off with route_usage_enabled, unlike the shadow log next
door: that one stores what people typed, this one stores four integers about a day.
2026-09-09 14:52:50 +02:00
Maurice 88129e4546 feat(maps): search the view in road trip mode too
The category pill was switched off whenever road trip mode was on, which left the
corridor search as the only way to find anything. The two answer different questions
though — 'what is along this drive' against 'what is in view right now' — and looking
for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation
at a stop for the same reason (disc #1797 thread).

Both searches now draw together. A place found by both appears once, as the corridor
knows it, because only that copy carries how far along the drive it sits, and the
merged list keeps its reference while nothing changes so the map does not rebuild
every pin on each render.
2026-09-09 14:52:50 +02:00
Maurice a46d3fe88a fix(i18n): reword three literal German road trip strings
'Wähle aus, was du brauchst, und suche' is a word-for-word rendering of 'Pick what
you need and search' that no German speaker would write; 'Wieder ein Ziel' is not a
sentence a button can carry; and 'Alle Leistungen' reads as services rather than
charging power in a dropdown of kilowatts.
2026-09-09 14:52:50 +02:00
Maurice 149b87fd47 feat(roadtrip): give the day boundary its own band, and flag a late pause
Three things the rail was quietly getting wrong at a day boundary.

A stop reached later than the time set on it showed a warning only when it carried
a number. The schedule restarts its chain at any anchor, whatever kind of stop holds
it, so the finding was computed for a fuel or charging halt too and then thrown away.
Both branches now draw it, from one component, and it sits in the badge row beside
the stay rather than on a line of its own.

The midnight crossing was a small pill floating in the middle of the rail, which read
as a badge belonging to the stop underneath. It is now a band across the full width
that breaks the dashed line instead of letting it run through, with the mascot asleep
in the gap. Both its animations stop under prefers-reduced-motion.

And the stay dialog said 'leave 01:00' for a departure the chain places on the next
day, because the clock wraps modulo 24 h. It now carries the same day marker the rail
has always shown beside such a time.
2026-09-09 14:52:50 +02:00
Maurice 15beb0ea40 feat(roadtrip): work back from the first pinned time
The schedule only ever walked forward, so a stop before the first pinned time stayed
blank. That is the wrong way round for how people plan: the museum opens at ten, so
when do we have to leave? Pinning the time on the second stop and reading the first
one's departure off the chain is the whole point of pinning it.

The chain now runs both ways — forward from the first known arrival as before,
backward for everything ahead of it, departure being the next arrival minus the
drive and arrival that minus the stay. It stops at a leg that never routed rather
than inventing a duration, and when working back crosses midnight the whole chain
shifts up a day so the earliest stop stays on day zero and the boundary is drawn
between the two stops instead of under the first one.
2026-09-09 14:52:50 +02:00
Maurice f283254bb0 fix(reservations): keep a car booking's stops when it is saved on a phone
The desktop form has offered stops between pick-up and return since the road trip
release (#1797). The phone sheet never knew about them: its car branch rebuilt the
endpoint list from from/to alone, and because the server replaces every endpoint row
of a booking, saving there silently dropped whatever the desktop had set.

Its car branch now reads the stop endpoints back into an editor and writes them out
again, the same shape TransportModal uses: location, time, reorder arrows, remove.
2026-09-09 14:52:50 +02:00
Maurice 8acfaea3c4 chore(deps): take the multer and nodemailer fixes for four HIGH CVEs
Docker Scout turned the release PR red overnight on advisories published
after the last green run:

  multer < 2.3.0      CVE-2026-82333, CVE-2026-77078, CVE-2026-77037
  nodemailer < 9.1.0  GHSA-2x7j-588g-ccc2

Both are patch-level resource-exhaustion fixes with no API change — multer
2.3.0 carries the same four dependencies as 2.2.0. multer resolves through
the root override (@nestjs/platform-express asks for 2.1.1), so the range
there has to move with it, and the server's own override list beside it.
2026-09-09 10:24:22 +02:00
Maurice a463246c1c fix(tests): let the spool handler stub carry a payload
`ReturnType<typeof of>` resolves against the zero-argument overload, so the
stub only accepted `Observable<never>` and SPOOL-001 could not hand it the
`of('ok')` it needs. tsc -p tsconfig.tests.json broke on it, which sank the
server preflight job and with it every coverage job that needs it.
2026-09-09 10:11:36 +02:00
Maurice 2a38e79431 test(collab): cover the image upload path and the spool cleanup
The chat images never reached a test: committing them to storage, taking them
back out when a later one fails, and dropping them again when the reply target
turns out to be gone. The interceptor that removes the spool when a request
dies before the handler had none either, and it sits in a domain pinned at 99%
function coverage. Both filters are pinned too, including the disguised
extensions that were the point of changing them.
2026-09-09 00:11:00 +02:00
Maurice 2d2f1e88bd fix(collab): sync shared links live, and teach the harnesses the new columns
The three collab:link events were broadcast and landed nowhere: the links
panel held its own list and never listened, so a link somebody else added
showed up on the next reload. It listens now, the same shape the notes panel
uses, and the events are classified in the client's WS policy, which is the
ratchet that caught this.

Four e2e harnesses build their own trip_files and one asserts the collab
feature set, so the new message_id column and the new links flag had to reach
them too. Two MCP unit tests pinned the old four-key feature object.
2026-09-09 00:11:00 +02:00
Maurice 1e2a5e3862 fix(shared): keep the link contract free of DOM globals
The URL check used `new URL(...)`, and this package compiles against lib
ES2022 alone so that it carries neither DOM nor Node globals, which is why the
shared typecheck refused it. `z.url()` plus a protocol test does the same job
with names the package actually has; z.url() on its own would take mailto: and
javascript:.

The three collab:link events also needed their fixtures and their place in the
pinned inventory count, the guard that stops the fixture map drifting from the
registry.
2026-09-09 00:11:00 +02:00
Maurice 7e3b9fd039 fix(collab): keep every panel on screen, and stop leaking spool and links
The desktop layout rendered notes and links as a pair and dropped whichever of
the two was on its own, so turning the new Links feature off on an otherwise
default install made the Notes panel disappear. Both rows are now split by kind
and rendered only when they hold something, and one function decides what a
panel id renders, because the branch that lost a panel did so by not listing it.

The message and its attachment rows are written in one transaction. The caller
has already committed the image bytes by then, so a message that landed without
them left those bytes with nothing pointing at them and nothing to sweep them.
Deleting one now drops the blobs after the rows are gone rather than before.

A body the Zod pipe rejects used to leave up to four spooled images on disk:
pipes run after interceptors, so multer had already written them and the
handler's own cleanup was never reached. An interceptor removes them on any
failure before the handler, which also covers every other schema mismatch, and
the length check that could never fire is gone.

Adding, pinning and deleting a link now says something when it fails instead of
raising an unhandled rejection, the list has real types, and the rejected-image
toast is translated in all 23 locales rather than shipping English everywhere.
The picking, previewing and clearing of chat images moved into one hook both
shells use: it was copied into two, and both rebuilt every preview on each
change and leaked an object URL per image per pick.

Tests: the four link routes end to end with the validation pipe the harness was
missing, the image filter against the disguised-extension case, and the layout
combinations that drop a panel.
2026-09-09 00:11:00 +02:00
Maurice 07bb899c50 fix(collab): close the image upload hole and the guard trap
The chat image filter believed the Content-Type the client put on the part.
The stored name keeps the extension of the name the client sent, and the
download route derives what it serves from that extension and sends it inline,
so pwn.html declared as image/png came back as HTML on our own origin. The
filter now checks the extension as well, against the blocked list and against
the types it claims to accept, because a per-route filter fully replaces the
module-level one it looked like it was adding to.

The multipart message route also kept its guard decorators. Guards run before
the interceptor, so a refusal went out while the client was still streaming up
to four images and the socket died as ECONNRESET rather than carrying the
error. The checks moved into the handler, where the other upload route in this
file already does them, and a test pins both refusals.

The three collab:link events were missing from the shared registry, which the
server typecheck refuses, and the admin Links switch could never be saved
because the Zod contract had no key for it. Both are in now. Migrations moved
to the end of the list and the column add uses the pragma guard rather than a
caught duplicate-column error.

The message-required string keeps its old wording for a request without a file
part, so a client from before this route took images sees what it always saw.
2026-09-09 00:11:00 +02:00
Godric Hood 9543fdc3f2 feat(collab): add shared links, chat images, and plugin fullscreen
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
2026-09-09 00:11:00 +02:00
Maurice 59a1756529 style(admin): lay the admin panel out in two columns and match the banner buttons
The User Defaults and Notifications tabs were one narrow column of cards on a
page that has no max width, so the right two thirds stayed empty while the page
scrolled for ages. Both now use the two-column grid the Settings tab already
has, grouped by subject rather than by height.

User Defaults splits into "Default User Settings" (colour mode, units, time
format, currency, booking codes) and a "Map" card holding template, keys,
routing base, preview and engine. The columns are deliberately uneven — the left
one never needs more than its widest option row, the right one holds four fields
whose values are 60-character tile URLs.

Notifications keeps the three channel switches together on the left, because they
all write the same notification_channels list, and puts the operator's own
targets on the right. The admin preference matrix moves in with them instead of
running full width underneath: it decides which of those channels each admin-only
event goes out over. Its label column gets minmax(0,1fr) so it wraps rather than
overflowing in the narrower column.

The transit provider was the one native <select> left on the Settings tab. The
option list of a native select is drawn by the OS, so it followed neither the
colour scheme nor the radius nor the user's text-size setting; it now uses the
CustomSelect the rest of the app uses, and skips the request when the option
already selected is picked again.

In the update banner, "View on GitHub" was a size smaller than "How to Update" on
every axis — 12px against 14px, a 14px icon against 16px, less padding, and 2px
shorter. Two buttons side by side read as one group, so that reads as a mistake
rather than as hierarchy. Same box now; the fill, the colour and the weight still
mark the primary action.
2026-09-09 00:09:34 +02:00
Maurice 4611f0ab7a test(files): give the e2e harness the column the receipt link adds
listFiles projects linked_budget_item_ids now, so the harness table it selects
from needs budget_item_id or every list call in that file answers 500. Same
gap the collab harness had.
2026-09-08 23:15:26 +02:00
Maurice 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
dersimoezdag 06ecc5135c feat(budget): attach receipts and invoices to expenses 2026-09-08 23:15:26 +02:00
Maurice b2a196d1e7 style(planner): soften the empty-day add button
A solid outline and a whisper of accent instead of the dashed grey placeholder
it replaced, so an empty day reads as an offer rather than as something
switched off, without competing with the day rows around it.
2026-09-08 20:41:57 +02:00
Maurice f5d6af9c77 fix(planner): steady satellite pill, responsive places rail, day-scoped add
The satellite toggle sat on an offset that included --day-panel-h, so it hopped
every time the day panel was collapsed or expanded. On the desktop map that
panel is a centred card between the sidebars and never reaches the pill at the
left edge, so the lift only applies on a phone now.

In the places rail the two add buttons split the width evenly once a day is
open, and every button drops to its icon when the rail is dragged narrow. The
filter tabs span the rail and keep their own text widths. The count is a badge
across the rail; the search field got the outline the controls around it use.

An empty day offers a button that opens the place form pointed at that day
instead of only stating that nothing is planned. The day is cleared again when
the form is opened to edit, which it was not before.

The trip tab bar no longer carries a native title repeating its own label; the
full name moved to aria-label for the tabs whose label is abbreviated.

Three maps tests drove the index switch through app_settings, which no longer
holds it, and now move TREK_PLACES_ENABLED instead.
2026-09-08 20:41:57 +02:00
Maurice 9fa0ceaf49 feat(places): add a place straight into the day that is open
The places pool sits one click away from the plan, and a place you already know
belongs to today had to go through the day picker afterwards to get there. With
a day open, the add button splits: the left half still opens an empty form, the
right half opens one that assigns the place to that day on save.

The plumbing was already there. placeFormDayId has assigned a freshly created
place to a day since #1998; nothing in the places rail ever set it.

The main label shortens to "New place" while the split is out, so both buttons
fit side by side in the rail without either one truncating. The second button
stays mounted and collapsed rather than unmounted, so it has something to
animate out of: width, padding and opacity over 180ms, and nothing at all under
prefers-reduced-motion. Collapsed it is also out of the tab order and hidden
from screen readers, because a zero-width button is still focusable otherwise.

Both columns of the place form are 320px now. Two panels of different widths
flanking one form read as a mistake rather than as a hierarchy, and neither of
them is the more important one.
2026-09-08 20:41:57 +02:00
Maurice 900e4806ad fix(places): centre the empty states, and give the details column one too
Five states, one shape. EmptyState grows a `fill` option: the stack already
centres what it holds, but with no height of its own there was nothing to centre
inside, so every one of them sat pinned under the header of its column.

Optically centred rather than geometrically. The extra room goes underneath,
which puts the mascot slightly above the middle: in a column you can scroll,
dead centre reads as having fallen to the bottom the moment the list is short.

The details column's own placeholder joins them, with the idle look rather than
the sad one, because nothing being picked yet is a state and not a fault. Its
line is shortened to fit on one: "Pick a result to see more", in all 23
languages.

The corridor panel, the saved-places list and the place details all dropped
`compact`, so the five now read at the same size as the road trip rail.
2026-09-08 20:41:57 +02:00
Maurice 4a88dbcbcf fix(places): say where a result came from, and stop asking questions in the form
Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
2026-09-08 20:41:57 +02:00
Maurice 1761f2bcd5 fix(maps): keep a browser without WebGL out of the error screen
The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
its WebGL context inside the Map constructor. A browser that refuses one throws
from there, and VectorBasemap attaches the layer inside an async effect, so the
throw became an unhandled rejection no boundary could see.

What actually took the page down was the teardown. Leaflet registers a layer
before onAdd runs, so the half-built one stayed on the map, and the bridge's
onRemove calls this._glMap.remove() without asking whether there is one. That
TypeError lands in a React cleanup, which is why the error appears on the way
out of a map rather than on the way in, and why it reads as "Something went
wrong" when a tab changes and "TREK could not start" when the route does.

The browser is now asked first, once per page load, with a one-pixel probe that
hands the context straight back. Without WebGL the basemap is OSM raster tiles
instead of an empty map, and the maplibre chunk, about a megabyte, is never
fetched. The teardown path is safe either way: a half-built GL layer gets a
no-op stand-in so the bridge can finish its own cleanup, and detaching never
throws, because unmounting a map must not fail just because its basemap did.

Reproduced against demo.liketrek.com with WebGL switched off, and verified with
the fix: 14 raster tiles drawn, no GL canvas, no error screen.

Closes #2288
2026-09-08 19:00:13 +02:00
jelly 8be594d34c fix(atlas): correct Guangdong Province name in region bundle 2026-09-08 16:36:47 +02:00
Luffy 5ac6ce40be fix(i18n): add translations for new notification event labels 2026-09-08 16:19:31 +02:00
Maurice 2d7889c237 fix(memories): give the photo picker a chronological order it can rely on
The picker got its photos back in whatever order the provider happened to
return them. It groups by day and appends pages lazily, so an unordered page
lands under the wrong heading and pushes photos out of the fold the reader
is looking at.

Immich now asks for order: desc on both the search and the album path. An
older server that does not know the property drops it silently rather than
failing, so each page is sorted again here; the album paths drain every page
before sorting and are covered outright. getAssetInfo reports mediaType, the
same mapping the listing already used, so the detail route and the picker
agree on what an asset is.

Synology gets no sort parameter upstream on either path. Search.Search
documents none, and _fetchSynologyJson maps every app code except 106/107/119
onto a 400, so a parameter one DSM build rejects would take listing down
entirely for that user. Both paths read every page anyway, so ordering the
mapped result costs nothing and cannot fail.

No media type for Synology yet, deliberately. streamSynologyAsset serves
every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is
no video byte path. Labelling an asset a video would make the lightbox mount
a player on a still image, which is worse than the missing badge. The badge,
the byte path and the persisted media_type belong together in their own
change.

Five album cases in the Immich integration suite pinned the old arrival
order. Where the position was incidental they now look the asset up by id;
where the order is the point they state the chronological one.
2026-09-08 15:24:21 +02:00
Maurice cfae0a1f78 fix(journey): stop the load-more observer rebuilding itself on every render
ScrollTrigger kept onVisible in its effect dependencies, and both callers hand
in a fresh arrow on every render: the picker its loadMorePhotos, the gallery
its setVisibleCount step. So every unrelated re-render, a photo being ticked
included, disconnected the IntersectionObserver and observed again, and each
observe() replays the initial callback.

The callback moves into a ref. loading stays a dependency, because re-observing
once a page has landed is the only thing that still produces a callback when
the sentinel never left the 200px margin, so folding that into a ref too would
leave paging dead after the first page. FE-COMP-JSCROLLTRIG-009 pins that, 007
and 008 pin the rebuild itself; both are red on the old component.

Markup, classes and the signature are untouched.
2026-09-08 15:09:51 +02:00
Maurice 31061751b0 fix(client): sort the picker grid in a memo again, not inside the fetches
Sorting inside searchPhotos and loadAlbumPhotos looks equivalent and is not.

contextLocation is live editor state. The place search and "use my location"
write it while the picker stays mounted, and the picker key is only provider
plus date, so nothing remounts it. Open an entry with no location, let the
photos load, then search for Rome: the caption flips to nearest-first and the
grid does not move.

The appending search made it worse than that. It sorted only the page it had
just fetched before appending, so from page two on the grid was a sequence of
sorted blocks rather than a sorted list, and a photo from page two landed under
a day heading page one had already closed.

FE-JRN-PICKER-021 pins it and is red without the memo.

The video badge also gets z-10 and pointer-events-none. It shares a stacking
context with the city gradient that follows it, so the later sibling was
winning on the geotagged tiles; pointer-events-none is what the eight existing
video overlays in the journey code all set.
2026-09-08 14:58:07 +02:00
xvilo 559b7624d0 fix(client): show pending state on Immich picker Add button
Clicking Add gave no feedback while the async onAdd call was in
flight, and nothing stopped a second click from firing a duplicate
submit. Adds a local pending flag that disables the button and swaps
its label for a spinner until the call resolves.
2026-09-08 14:58:07 +02:00
xvilo 2de9de8c3a feat(client): add video indicator to Immich picker thumbnails
Picker tiles gave no visual cue that an asset was a video rather than
a photo. Adds a small play-icon badge (bottom-left corner, matching
the desktop/mobile gallery grid's video treatment) driven by the
asset's existing mediaType field. No video preview -- just the
indicator.
2026-09-08 14:58:07 +02:00
xvilo d609aefe8f fix(client): keep Immich picker photo order stable and chronological by default
sortProviderPhotos previously passed photos through unchanged whenever
there was no valid journey location, leaving order entirely up to
Immich's search API (not guaranteed chronological). It now falls back
to newest-taken-first.

The picker also re-sorted the entire accumulated photo list on every
render, so loading another page could reorder tiles already on screen
when a location context was set -- newly loaded photos could land
above the fold instead of at the bottom, making it look like nothing
happened. Each fetched batch (search page or album) is now sorted once
at fetch time and appended, so earlier pages never get reshuffled by
later ones.
2026-09-08 14:58:07 +02:00
xvilo 1792fc17d0 style(client): run prettier on Immich picker files before functional changes
No logic change; isolates formatting noise from the upcoming picker fixes
so those diffs stay reviewable.
2026-09-08 14:58:07 +02:00
Maurice 72935d7e40 test(admin): wait for the migration POST instead of reading it after the toast
FE-MOB-MSTOR-013 read migrationBody straight after the save toast. The toast
comes from the PUT, the migration POST is fired by the queue effect one commit
later, so the two are not the same tick. It only passed while that effect
happened to flush first, which on a loaded runner it does not.

The desktop twin FE-ADMIN-STOR-030 has waited here since 8069b8805; the mobile
file was left behind.
2026-09-08 14:39:34 +02:00
Maurice 569f0ca1a5 test(mobile-nav): follow the new default dock through the mobile suites
Three expectations still described the Vacay/Atlas dock. Two of them
asserted the old arrangement by name, so they were pinning the very thing
that changed rather than catching a regression.

FE-MOB-NAV-006 now watches Atlas take the demoted seat next to
Collections, and picks Atlas out of the popover in FE-MOB-NAV-011 for
the same reason. Two new cases cover what the change is actually about:
Journey sits in the dock when it is enabled, and an instance without the
addon still gets Vacay and Atlas.
2026-09-08 14:39:34 +02:00
Maurice 5d0d106c70 chore: merge main into dev to clear the release conflicts
Nine files conflicted between the two branches and the merge changes not
one of them: the result is byte-identical to dev. Six were the version
string alone, 4.2.1 against 4.2.0, which the release job rewrites to
4.3.0 on the way to main anyway. The other three were places where dev
already carried main's change plus more, so dev's side wins in each.

Nothing is lost. Thirty of the thirty-four commits on main are
cherry-equivalent to dev already; of the rest, two are version bumps,
one is an empty tag commit, and the RTL text plugin fix is in dev under
its own commit, verified by the files it added rather than by its
message.

Also drops the copy of the LLM-ceiling test that main carried twice under
two names, which the merge would otherwise have reintroduced.
2026-09-07 23:26:18 +02:00
Maurice c04e740ff1 fix(mobile-nav): dock Journey by default, move Atlas one tap further
Feedback from Discord: on a phone in the field the two things you reach
for are the itinerary and the journal, but the default dock spends its
two free slots on Vacay and Atlas. Journey sits under More, and most
people never open the navbar customiser to fix that.

DEFAULT_DOCK_IDS becomes a priority list instead of a membership test:
the first MOBILE_NAV_MAX_BAR entries that are actually enabled take the
slots. Journey ships disabled, so an instance that never turned it on
keeps the exact Vacay/Atlas dock it has today. Listing all four known
global addons also fills the dock when one of them is switched off,
where the old membership test left the slot empty.

Accounts that saved their own bar/more split are untouched: a non-empty
config still wins over the default, so nobody's arrangement moves.
2026-09-07 23:18:23 +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
github-actions[bot] 515398f8ee chore: bump version to 4.2.1 [skip ci] 2026-09-06 14:13:46 +00:00
Maurice e4f2eaf336 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 16:11:52 +02:00
Maurice 16fee0800d 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 16:11:52 +02:00
Maurice 888cd4ee08 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 16:11:52 +02:00
Maurice 8fe90d1783 fix(test): the card is labelled Total trip spend 2026-09-06 16:11:52 +02:00
Maurice 4cda3c4b2e 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 16:11:52 +02:00
Maurice bc85ec1eea 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 16:11:52 +02:00
Maurice 04c22b34af fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 16:11:52 +02:00
Maurice 56a23cb809 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 16:11:52 +02:00
Maurice 251378be1d 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 16:11:52 +02:00
Maurice 973ce5f8ff 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 16:11:52 +02:00
Maurice bbc32310e9 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 16:11:52 +02:00
xvilo ad6e51c574 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 16:11:52 +02:00
xvilo 6da11994d9 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 16:11:52 +02:00
Maurice eef1f16836 test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-06 16:11:52 +02:00
Maurice 4a8d77c59c 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-06 16:11:52 +02:00
Maurice a70c2eb9e0 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-06 16:11:52 +02:00
Maurice 99b040f684 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-06 16:11:52 +02:00
Xheldon 944e0a0970 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-06 16:11:52 +02:00
Maurice b9f69b0c76 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-06 16:11:52 +02:00
Maurice fcefc7e771 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-06 16:11:52 +02:00
Mohamed Elkmeshi 6f0a171c74 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-06 16:11:52 +02:00
Maurice 3e1c06a0ee 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-06 16:11:52 +02:00
Xheldon 1aafab83a5 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-06 16:11:52 +02:00
Maurice c1d0435480 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-06 16:11:52 +02:00
Maurice 798859517f 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-06 16:11:52 +02:00
RemEx d3fdf78db6 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-06 16:11:52 +02:00
Maurice 1b71b8633d 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-06 16:11:52 +02:00
Maurice d77f1c1758 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-06 16:11:52 +02:00
Maurice e09c0688ae 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-06 16:11:52 +02:00
Maurice 0e66ae72ca 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-06 16:11:52 +02:00
jubnl 61bef334d5 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-06 16:11:52 +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
jubnl fa406b708f plugin-sdk-v1.7.0 2026-09-03 17:30:31 +02:00
github-actions[bot] 09ce5cb733 chore: bump version to 4.2.0 [skip ci] 2026-09-03 14:24:43 +00: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
1977 changed files with 204353 additions and 10575 deletions
+2
View File
@@ -26,3 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+12
View File
@@ -51,6 +51,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# The image's start-up shape is pinned by a unit test
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
# Dockerfile or to the entrypoint script has to run the suite — that is
# exactly the change the test exists to catch.
- 'Dockerfile'
- 'server/scripts/**'
# 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.
@@ -69,6 +75,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# The image's start-up shape is pinned by a unit test
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
# Dockerfile or to the entrypoint script has to run the suite — that is
# exactly the change the test exists to catch.
- 'Dockerfile'
- 'server/scripts/**'
# 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.
+66 -51
View File
@@ -1,18 +1,17 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
## Commands
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
npm run format # prettier --write all three
```
@@ -29,17 +28,14 @@ Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
cd client && npm run e2e # Playwright (local only)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
Run a single test file or test:
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
## Server architecture
## Server invariants
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
## Client architecture
## Client invariants
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
## Project direction
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
| Condition (new code) | Must be |
|---|---|
| Coverage | ≥ 80% |
| Duplicated lines | ≤ 3% |
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
+14 -7
View File
@@ -20,6 +20,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
@@ -53,7 +54,7 @@ COPY server/package.json ./server/
# would copy up every inode it touches and duplicate the whole tree in the image.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
@@ -61,7 +62,8 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
# it to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
@@ -116,9 +118,14 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
# Start-up lives in a script, not an inline `sh -c` string: container management
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
# carrying quotes does not survive that round-trip (the container then dies with
# a shell syntax error before Node starts). A CMD of one bare token does.
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
# node. chmod is explicit because a checkout on Windows carries no exec bit.
RUN chmod 0755 /usr/local/bin/trek-entrypoint
ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
CMD ["/usr/local/bin/trek-entrypoint"]
+3 -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,8 +46,10 @@ 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.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `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.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.1.0
version: 4.3.0
description: Minimal Helm chart for TREK app
appVersion: "4.1.0"
appVersion: "4.3.0"
+18
View File
@@ -43,6 +43,9 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
@@ -73,9 +76,24 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
{{- if .Values.env.NOMINATIM_URL }}
NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
otherwise drop the key and leave the index on. */}}
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
+18 -10
View File
@@ -75,23 +75,31 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_KEY
optional: true
- name: AMAP_API_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_SECRET
optional: true
volumeMounts:
- name: data
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 }}
+12
View File
@@ -20,6 +20,12 @@ data:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -50,4 +56,10 @@ stringData:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
{{- end }}
{{- end }}
+54 -2
View File
@@ -54,6 +54,9 @@ env:
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# ALLOW_LINK_LOCAL_IPS: ""
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
@@ -78,12 +81,33 @@ env:
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# NOMINATIM_URL: ""
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
# way. Defaults to https://nominatim.openstreetmap.org.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# OVERPASS_TIMEOUT_MS: "25000"
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# Secret environment variables stored in a Kubernetes Secret.
@@ -109,6 +133,15 @@ secretEnv:
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
# key from the admin panel, or none.
AMAP_API_KEY: ""
# The private secret (数字签名) of that key. Required exactly when the key was created
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
@@ -134,6 +167,25 @@ persistence:
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
probes:
liveness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readiness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
+45 -53
View File
@@ -1,77 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
npm run typecheck # tsc --noEmit (CI)
npm run lint # eslint . (lint:check in CI)
npm run lint:pages # enforce the Page pattern (CI gate)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (local only; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
```
Single test:
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails otherwise)
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Direction (from the 2026 audit)
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
## Other big-picture pieces
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop ↔ mobile shells and the SonarQube duplication gate
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
+10 -1
View File
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// An iPad is inside the 768-1023px band, where only one side panel is open at a
// time so the map keeps a usable width (#2247). The places list is one tap away.
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
// "Plan" button of its own.
await page.locator('button[aria-label="Places"]').click()
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
})
+10 -7
View File
@@ -1,11 +1,11 @@
{
"name": "@trek/client",
"version": "4.1.0",
"version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -26,7 +26,9 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
@@ -37,6 +39,7 @@
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
@@ -48,7 +51,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
@@ -78,7 +81,7 @@
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
@@ -93,12 +96,12 @@
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"sharp": "^0.35.0",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vitest": "^4.1.11"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-tower-control-icon lucide-tower-control">
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
<path d="M8 13v9" />
<path d="M16 22v-9" />
<path d="m9 6 1 7" />
<path d="m15 6-1 7" />
<path d="M12 6V2" />
<path d="M13 2h-2" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+25
View File
@@ -0,0 +1,25 @@
import { createRequire } from 'node:module';
import path from 'node:path';
/**
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
* with importScripts. mapbox-gl-rtl-text ships exactly that as
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
* and stepping across to dist keeps this working wherever npm hoists the package.
*
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
* asset, and the test run needs it because a lazily imported engine module reaches
* the same specifier.
*
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
* which a string alias will not match. It is left unanchored on purpose so the
* query survives the rewrite and Vite still emits an asset URL.
*/
export const rtlTextAlias = {
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
'../dist/mapbox-gl-rtl-text.js',
),
};
+21
View File
@@ -26,4 +26,25 @@ for (const { name, size } of sizes) {
console.log(` \u2713 ${name} (${size}x${size})`);
}
// Maskable variants: Android lays a maskable icon edge to edge under the round
// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80%
// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and
// recenter it, keeping the brand gradient as the full background.
const maskableSvg = svgBuffer
.toString()
.replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)');
const maskableSizes = [
{ name: 'icon-maskable-192x192.png', size: 192 },
{ name: 'icon-maskable-512x512.png', size: 512 },
];
for (const { name, size } of maskableSizes) {
await sharp(Buffer.from(maskableSvg), { density: 300 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(join(iconsDir, name));
console.log(` \u2713 ${name} (${size}x${size}, maskable)`);
}
console.log('PWA icons generated.');
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
const require = createRequire(import.meta.url)
const packagePath = require.resolve('maplibre-gl/package.json')
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
const loops = [
['of elem.attributes)', 'of Array.from(elem.attributes))'],
['of e.attributes)', 'of Array.from(e.attributes))'],
]
for (const filename of [
'src/util/dom.ts',
'dist/maplibre-gl.js',
'dist/maplibre-gl-dev.js',
'dist/maplibre-gl-csp.js',
'dist/maplibre-gl-csp-dev.js',
]) {
const target = join(dirname(packagePath), filename)
const code = readFileSync(target, 'utf8')
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
const [before, after] = loop
if (code.split(before).length === 1 && code.split(after).length === 2) continue
if (code.split(before).length !== 2 || code.includes(after)) {
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
}
writeFileSync(target, code.replace(before, after))
}
+77
View File
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
// The old handler wiped every Cache Storage bucket and every registration
// here, which left the device with no app shell until a fresh ~22 MB
// precache finished: anyone who closed the app or lost signal inside that
// window could no longer start the PWA offline at all, and it also threw
// away the map tiles and documents the user had downloaded on purpose.
localStorage.setItem('trek_app_version', '2.9.9')
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
const update = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
addEventListener: vi.fn(),
},
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
// It asks the worker to fetch the new build; Workbox swaps the precache
// atomically once that install completes.
await waitFor(() => expect(update).toHaveBeenCalled())
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
})
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
// The value arrives over the wire and is written to this device's storage,
// so it is validated rather than trusted. An unusable one must also not be
// compared against the stored marker, or every launch would see a mismatch
// and try to update.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a
// throwing step left it unwritten and the whole destructive path repeated on
// every launch, for good.
localStorage.setItem('trek_app_version', '2.9.9')
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
})
})
// Regression: a device that has never mirrored the preference must not treat
+53 -18
View File
@@ -19,11 +19,12 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
@@ -283,7 +284,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, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -300,13 +301,14 @@ 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; has_amap_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)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -314,26 +316,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(() => {})
}, [])
@@ -341,6 +366,7 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useRoadtripPreferencesSync()
useEffect(() => {
if (isAuthenticated) {
@@ -380,13 +406,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,
+7
View File
@@ -0,0 +1,7 @@
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
import { apiClient } from './client'
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
return assignmentSchema.parse(saved.data.assignment)
}
+48 -1
View File
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
// text/html is not proof of a proxy: several of TREK's own routes answer
// res.status(401).send('Authentication required'), which Express labels
// text/html. Tearing the worker down for one of those costs the user
// offline mode for a wall that is not there, so confirm reachability first.
probeNow.mockResolvedValue('online')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(probeNow).toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -367,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
)
})
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () =>
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
)
// Appended, not interleaved: the core list keeps the order it earned.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
source: 'openstreetmap+plugin:demo',
})
})
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
)
// The optional index being unwell must never cost the search that worked.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }],
source: 'openstreetmap',
})
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
+85 -2
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-054
// FE-APISURF-001 to FE-APISURF-057
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -10,6 +10,7 @@ import {
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -301,6 +302,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' },
@@ -410,9 +414,22 @@ describe('client > endpoint wiring', () => {
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
/**
* The one call in this file that is deliberately TWO requests, which is why it is not
* in the list above: a search asks TREK's own indexes and any installed search-provider
* plugin at the same time, and the caller gets one list back (#2221).
*/
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
log = []
await mapsApi.search('Rome')
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
'GET /api/plugin-search',
'POST /api/maps/search',
])
})
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' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
@@ -649,6 +666,12 @@ describe('client > request payloads', () => {
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => {
const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway'))
expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes')
expect(rec.body).toEqual({ name: 'Norway' })
})
})
describe('client > query parameters', () => {
@@ -862,4 +885,64 @@ 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 })
}
})
it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => {
const post = spyPost()
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
// The server allows each request to a store 15 s, and a run waits for the
// whole listing and every transfer; cut off at 8 s the browser reported a
// failure while the server carried on and finished.
await docsyncApi.testConnection(1, { providerId: 'paperless' })
await docsyncApi.createScope(1, 5, 'Norway')
await docsyncApi.createLink(1, { connectionId: 5 })
await docsyncApi.listScopes(1, 5)
await docsyncApi.deleteLink(1, 9)
expect(post.mock.calls.map(c => c[0])).toEqual([
'/trips/1/docsync/connections/test',
'/trips/1/docsync/connections/5/scopes',
'/trips/1/docsync/links',
])
for (const call of post.mock.calls) {
expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
}
expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes')
expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9')
expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000)
// A run, and a conflict choice that runs the binding afterwards.
post.mockClear()
await docsyncApi.syncNow(1, 9)
await docsyncApi.resolve(1, 4, 'trek')
expect(post.mock.calls.map(c => c[0])).toEqual([
'/trips/1/docsync/links/9/sync',
'/trips/1/docsync/items/4/resolve',
])
for (const call of post.mock.calls) {
expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS })
}
expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS)
})
})
+401 -37
View File
@@ -1,6 +1,8 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
@@ -19,6 +21,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type ApiTokenCreateRequest, type PublicApiScope,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
@@ -30,7 +33,7 @@ import {
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -53,6 +56,18 @@ import {
type StorageConfigPut,
type StorageTestResponse,
type StorageUsage,
type PluginSettingsField,
type PluginInstanceConfigResponse,
type PluginInstanceConfigUpdated,
type PluginActionDescriptor,
type PluginActionResult,
type PluginInstallRequest,
RoadtripDayTrack,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
@@ -122,7 +137,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
function translateRateLimit(): string {
const fallback = RATE_LIMIT_MESSAGES['en']!
try {
const lang = localStorage.getItem('app_language') || 'en'
const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en'
return RATE_LIMIT_MESSAGES[lang] ?? fallback
} catch {
return fallback
@@ -208,16 +223,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)
}
}
}
}
@@ -320,7 +343,10 @@ export const authApi = {
// 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),
// `scopes` narrows what the key may read (#2279). Omitted means everything,
// which is what every key minted before scopes existed still does.
create: (name: string, scopes?: PublicApiScope[]) =>
apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
@@ -418,7 +444,10 @@ export const daysApi = {
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
// so every hotel booking minted an unlinked duplicate (#2243).
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
@@ -447,8 +476,15 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
// A longer timeout than the shared 8 s, like the other routes here that wait
// on somebody else's service. A directions link whose stops are only named
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
// eight stops needs about ten seconds. Giving up at eight left the server
// finishing the import and writing the places while the browser reported a
// failure, and a retry then spent the whole geocoding budget again only to
// have the dedupe skip every stop.
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
@@ -467,6 +503,8 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Day-specific note on an assignment (#2163) — null clears it.
updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
@@ -535,8 +573,8 @@ export const adminApi = {
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
@@ -556,6 +594,17 @@ export const adminApi = {
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
// The admin-owned `scope:'instance'` settings: the declared fields plus the stored
// values (secrets masked). Saving may RESTART a running plugin — the child gets its
// config once at init — and `restarted` reports whether that happened.
pluginConfig: (id: string): Promise<PluginInstanceConfigResponse> =>
apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data),
pluginSaveConfig: (id: string, config: Record<string, unknown>): Promise<PluginInstanceConfigUpdated> =>
apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data),
// Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the
// clicking admin. 404 when the plugin is inactive (no child process to run it).
runPluginAction: (id: string, key: string): Promise<PluginActionResult> =>
apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data),
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
// the plugin with the widened allow-list.
@@ -616,12 +665,16 @@ 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),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -782,16 +835,13 @@ export interface PluginAtlasLayer {
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
}
export interface PluginUserSettingField {
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
hint?: string | null; required?: boolean; secret?: boolean;
options?: Array<{ value: string; label: string }>
}
/** The settings-field descriptor is the SHARED contract (both scopes emit the same
* shape) — aliased so existing per-user settings consumers keep their import path. */
export type PluginUserSettingField = PluginSettingsField
/** A button a plugin contributes to its own settings page ("Test connection"). */
export interface PluginAction {
key: string; label: string; hint?: string; danger: boolean
}
/** A button a plugin contributes to a settings form ("Test connection", "Purge cache").
* `scope` says which form: the user tab or the admin instance-settings dialog. */
export type PluginAction = PluginActionDescriptor
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
@@ -862,7 +912,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).
@@ -953,6 +1003,9 @@ export const journeyApi = {
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
@@ -972,6 +1025,9 @@ export const journeyApi = {
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
/** Bring every waved-away trip suggestion back. Answers with how many returned. */
restoreSuggestions: (id: number): Promise<{ restored: number }> =>
apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
@@ -994,7 +1050,10 @@ 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) =>
// utc_offset_minutes says which 24 hours from/to name. It is NOT the Synology
// `offset`, which is rows to skip: one body goes to whichever provider, so the
// two names have to stay apart (#2336).
search: (provider: string, body: { from: string; to: string; page: number; size: number; utc_offset_minutes?: 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),
@@ -1006,11 +1065,193 @@ export const memoriesApi = {
}).then(r => r.data),
}
/**
* Place search that still answers with no network.
*
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
* is the one place it is read from, so all ten call sites got the offline path
* without any of them learning about it. `source` says 'offline-cache' rather
* than pretending to be the index — several screens show it, and a stale answer
* that claims to be live is worse than one that says what it is.
*
* Only network-level failures fall through. A 4xx or 5xx means the server did
* answer and the caller has to see it.
*/
/**
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
*
* Separate because the shape is different: this one is keyed by id rather than
* by a query, and a miss has to stay a miss — a place the cache does not hold
* must fail the way it did before, not answer with somebody else's record.
*/
/**
* The rejection both search surfaces already know to swallow.
*
* They filter on the axios cancel code and on the DOMException name, so an
* abort from the cache path has to look like one or it surfaces as a toast.
*/
function abortedError(): Error & { code: string } {
const err = new Error('canceled') as Error & { code: string }
err.name = 'CanceledError'
err.code = 'ERR_CANCELED'
return err
}
async function withCachedPlace(
placeId: string,
online: () => Promise<{ place: Record<string, unknown> | null }>,
): Promise<{ place: Record<string, unknown> | null }> {
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const hit = await getCachedPlace(placeId)
return hit ? { place: cachedToPlaceRecord(hit) } : null
}
if (isEffectivelyOffline()) {
const cached = await fromCache()
if (cached) return cached
return online()
}
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached) throw err
return cached
}
}
async function withCachedPlaces<T>(
query: string,
shape: (places: Record<string, unknown>[]) => T,
online: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const fromCache = async (): Promise<Record<string, unknown>[]> => {
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
// The keystroke this answers may already be two keystrokes old. Both search
// surfaces order their suggestions by aborting the previous request and
// discarding the rejection — there is no request counter — so an offline
// answer that resolves regardless of the signal can overwrite a newer list.
// The two cache reads are also very unequal: a prefix hit comes off the
// index, the substring pass walks every cached place of every trip.
if (signal?.aborted) throw abortedError()
return found
}
if (isEffectivelyOffline()) return shape(await fromCache())
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached.length) throw err
return shape(cached)
}
}
/**
* What plugins implementing `searchProvider` found for the same query (#2221).
*
* Its own request beside the core one rather than a branch inside it: the core search
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
* answers with an empty list for every failure there is — no provider installed, one
* that timed out, a 404 on an older server, the network gone — because a search that
* breaks when an optional index is unwell is worse than one without it.
*/
/**
* How long a plugin index may keep the search list waiting, in milliseconds.
*
* The host gives a provider two seconds to answer and this gives the round trip a
* little more. It is a ceiling on the WAIT, not on the provider: with no search
* plugin installed the route answers immediately without reaching any of them, so
* the normal case costs one local round trip and nothing else.
*
* A deadline rather than patience, because the alternative is a search that feels
* broken. A list that arrives without an optional index is a smaller answer; a list
* that arrives four seconds late is no answer at all.
*/
const PLUGIN_SEARCH_DEADLINE_MS = 2500
async function pluginSearchPlaces(
query: string,
lang?: string,
near?: { lat: number; lng: number },
): Promise<Record<string, unknown>[]> {
const stop = new AbortController()
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
try {
const r = await apiClient.get('/plugin-search', {
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
signal: stop.signal,
})
const places = (r.data as { places?: unknown })?.places
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
} catch {
return []
} finally {
clearTimeout(timer)
}
}
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.
*
* Plugin indexes are asked at the same time and their hits are appended, so every
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
// Side by side, so the wait is the slower of the two rather than their sum. Only
// the core call may reject: that rejection is what hands withCachedPlaces the
// offline path, and a plugin failure must never trigger it.
const [core, extra] = await Promise.all([
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
pluginSearchPlaces(query, lang, locationBias),
])
if (extra.length === 0) return core
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
}),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
withCachedPlaces(
input,
(places) => ({
suggestions: places.map((p) => ({
placeId: String(p.osm_id),
mainText: String(p.name),
secondaryText: String(p.address || ''),
})),
source: 'offline-cache',
}),
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
signal,
),
// Answered from the cache when the network is not there, for the ids the
// offline suggestion list hands out. Without it, picking an offline
// suggestion failed here and the callers fell back to searching for
// "name, address" — which the cache matches on the folded NAME alone, so it
// found nothing and the user got an error toast for a place that was sitting
// in the cache all along.
details: (placeId: string, lang?: string, sessionToken?: string) =>
withCachedPlace(placeId, () =>
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
@@ -1030,10 +1271,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),
@@ -1049,8 +1340,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -1070,6 +1361,64 @@ export const filesApi = {
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
/**
* How long the browser waits for a document-sync call that makes the server go
* and ask the store.
*
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
* these, for the reason `dawarich.ts` gives: the server allows each request to
* a store 15 s by itself (20 s for Papra), a connection test may probe more
* than once, and binding or unbinding first registers or removes a webhook at
* the store. Cut off at 8 s, the browser reported a failure while the server
* carried on and finished, so a listing that took twelve seconds read as a
* broken store.
*/
export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000
/**
* A run is longer again. The server answers only once the whole of it is
* through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s
* each, and Paperless-ngx holds every push until its consume task has run.
* Resolving a conflict runs the binding afterwards, so it waits just as long.
* Ten minutes covers a full transfer budget of ordinary documents; a run that
* outlasts it still finishes on the server, and the `docsync:changed` ping
* refreshes the dialog when it does.
*/
export const DOCSYNC_RUN_TIMEOUT_MS = 600_000
const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }
const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS }
/**
* Document sync: one provider connection per trip, shared by every member.
*
* Trip-scoped rather than user-scoped on purpose: a per-user connection would
* make a document's visibility depend on whose credentials fetched it, which is
* the opposite of what a shared trip needs.
*/
export const docsyncApi = {
providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data),
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data),
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data),
createScope: (tripId: number | string, connectionId: number, name: string) =>
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data),
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data),
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data),
syncNow: (tripId: number | string, linkId: number, full = false) =>
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data),
items: (tripId: number | string, state?: string) =>
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
@@ -1108,7 +1457,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')),
}
@@ -1145,7 +1495,7 @@ export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
delete: (tripId: number | string, id: number, opts?: { keepStop?: boolean }) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`, opts?.keepStop ? { params: { keepStop: 'true' } } : undefined).then(r => r.data),
}
export const dayNotesApi = {
@@ -1162,13 +1512,19 @@ export const collabApi = {
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
@@ -1202,11 +1558,13 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
// Public transit routing (#1065) — proxied through the server, which picks the
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
// backend as the languageCode, so station names come back in the user's script.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
@@ -1220,15 +1578,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')),
}
+28 -6
View File
@@ -1,4 +1,4 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
@@ -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 () => {
@@ -339,4 +342,23 @@ describe('collectionsApi', () => {
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => {
server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })))
const res = await collectionsApi.exportGpx(1)
expect(requestUrl).toContain(`${BASE}/1/export/gpx`)
expect(res).toEqual({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })
})
it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => {
const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] }
server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 })))
const res = await collectionsApi.readGpx({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(requestBody).toEqual({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(res).toEqual({ file, skipped: 0, track_points: 12 })
})
})
+28 -2
View File
@@ -10,6 +10,13 @@ import type {
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -53,9 +60,28 @@ 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 }> =>
// Export / import as a file (#2198). The export body IS the file; the import
// sends the parsed file back through the same contract.
exportFile: (id: number): Promise<CollectionFile> =>
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
/** The same file into a list that already exists: it only ever adds to it. */
importFileInto: (id: number, body: CollectionImportIntoRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data),
// The same list as GPX (#2301). A GPX is read by the server into a list file,
// which then goes through importFile above like any other.
exportGpx: (id: number): Promise<CollectionGpxExport> =>
ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data),
readGpx: (body: CollectionGpxReadRequest): Promise<CollectionGpxReadResult> =>
ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data),
// 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),
+459
View File
@@ -0,0 +1,459 @@
/**
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
* for how long the browser waits.
*
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
* goes into the body or the query string. There is no logic in it to get wrong,
* which is exactly why a mistake here is so quiet: a wrong path or a dropped
* query parameter does not throw, it hands the layer above an empty answer, and
* the panel three levels up simply shows nothing. Every method is therefore
* driven through MSW and its verb, path and payload pinned.
*
* Three things here matter more than the URL bookkeeping:
*
* - **`offset`.** Recorded points are bare instants, so the reader's own UTC
* offset is the one fact the server cannot derive. Both track calls send it,
* and they send it negated: `getTimezoneOffset()` counts minutes *behind*
* local time, the wire wants minutes *ahead* of UTC. Were it to stop being
* sent, or to be sent unsigned, an evening east of UTC would land on the
* previous day of the trip and nothing would fail anywhere. The line would
* just be drawn on the wrong day, which is the whole reason the parameter
* exists. Hence two zones rather than one: a single European fixture cannot
* tell a negation from a magnitude.
* - **The failure reaches the caller intact.** `useDawarichConnection` and
* `useDawarichSuggestions` both translate `err.response.data.code` into the
* reader's language. A `.catch()` added anywhere in this file would turn
* every upstream failure into a silent success with an undefined body.
* - **An abort really cancels.** The track overlay issues a fresh request
* whenever the looked-at window changes and aborts the previous one; a call
* that resolved anyway would repaint the map with the older answer.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
import apiClient from './client'
import type {
DawarichAtlasSuggestions,
DawarichBucketScan,
DawarichConnection,
DawarichSuggestion,
DawarichSuggestionList,
DawarichTrack,
} from '@trek/shared'
const BASE = '/api/integrations/dawarich'
let requestUrl = ''
let requestBody: unknown
/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
requestUrl = request.url
const text = await request.text()
requestBody = text ? JSON.parse(text) : undefined
return HttpResponse.json(data)
}
}
function query(): URLSearchParams {
return new URL(requestUrl).searchParams
}
const connection: DawarichConnection = {
url: 'https://dawarich.example',
apiKeyMasked: '••••••••',
allowInsecureTls: false,
syncEnabled: true,
connected: true,
lastSyncAt: '2026-09-13T21:00:00Z',
lastSyncState: 'ok',
lastSyncError: null,
capabilities: null,
}
const suggestion: DawarichSuggestion = {
id: 41,
sourceVisitId: 'v-41',
tripId: 7,
tripTitle: 'Koeln',
name: 'Cafe Reichard',
lat: 50.94,
lng: 6.96,
startedAt: '2026-09-10T10:15:00+02:00',
endedAt: '2026-09-10T12:40:00+02:00',
durationMinutes: 145,
localDate: '2026-09-10',
sourceStatus: 'suggested',
confidence: 82,
confidenceBand: 'high',
state: 'new',
target: null,
acceptedPlaceId: null,
acceptedJournalEntryId: null,
acceptedBucketListItemId: null,
sourceChanged: false,
sourceMissing: false,
matchedBucketListItemId: null,
matchedBucketListName: null,
countryCode: 'DE',
firstSeenAt: '2026-09-10T06:00:00Z',
lastSeenAt: '2026-09-10T06:00:00Z',
}
const suggestionList: DawarichSuggestionList = {
suggestions: [suggestion],
connected: true,
lastSyncAt: '2026-09-13T21:00:00Z',
lastSyncState: 'ok',
lastSyncError: null,
}
const track: DawarichTrack = {
days: [{ date: '2026-09-10', segments: [] }],
source: 'tracks',
fetchedAt: '2026-09-13T21:05:00Z',
pointCount: 0,
truncated: false,
}
const bucketScan: DawarichBucketScan = {
matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }],
skippedWithoutCoordinates: 1,
truncated: false,
fetchedAt: '2026-09-13T21:05:00Z',
}
const atlas: DawarichAtlasSuggestions = {
countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }],
unresolved: ['Kosovo'],
fetchedAt: '2026-09-13T21:05:00Z',
}
beforeEach(() => {
requestUrl = ''
requestBody = undefined
// Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the
// value on the wire is +120 (positive for a zone ahead of UTC), which is the
// sign convention the server's day resolution expects.
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('dawarichApi connection', () => {
it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => {
server.use(http.get(`${BASE}/settings`, record(connection)))
const res = await dawarichApi.getSettings()
expect(requestUrl).toContain(`${BASE}/settings`)
expect(res).toEqual(connection)
})
// The three warning fields are why this endpoint does not just answer
// `{ success }`: the server writes its sentence in English, the client renders
// the code in the reader's language and quotes the resolved address back.
// Dropping any of them leaves a private-IP connection looking perfectly healthy.
it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => {
server.use(
http.put(
`${BASE}/settings`,
record({
success: true,
warning: 'That host resolves to a private address.',
warningCode: 'private_ip',
warningIp: '192.168.178.72',
}),
),
)
const res = await dawarichApi.saveSettings({
url: 'https://dawarich.lan',
apiKey: 'secret',
allowInsecureTls: true,
syncEnabled: false,
})
expect(requestBody).toEqual({
url: 'https://dawarich.lan',
apiKey: 'secret',
allowInsecureTls: true,
syncEnabled: false,
})
expect(res.warningCode).toBe('private_ip')
expect(res.warningIp).toBe('192.168.178.72')
expect(res.warning).toContain('private address')
})
it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => {
server.use(http.delete(`${BASE}/settings`, record({ success: true })))
expect(await dawarichApi.disconnect()).toEqual({ success: true })
expect(requestUrl).toContain(`${BASE}/settings`)
})
// A probe runs before anything is stored, so the body is deliberately partial:
// a url with no key is the shape the connect dialog sends on its first attempt.
it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => {
server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' })))
const res = await dawarichApi.test({ url: 'https://dawarich.example' })
expect(requestBody).toEqual({ url: 'https://dawarich.example' })
expect(res.connected).toBe(false)
expect(res.error).toBe('unauthorized')
})
// `alreadyRunning` is how a second press of Sync Now is told apart from a sync
// that genuinely found nothing; both report created/updated/missing as zero.
it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => {
server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true })))
const res = await dawarichApi.syncNow()
expect(requestUrl).toContain(`${BASE}/sync`)
expect(res.alreadyRunning).toBe(true)
})
})
describe('dawarichApi suggestions', () => {
it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => {
server.use(http.get(`${BASE}/suggestions`, record(suggestionList)))
const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' })
expect(query().get('tripId')).toBe('7')
expect(query().get('state')).toBe('new')
expect(res.suggestions).toHaveLength(1)
await dawarichApi.listSuggestions()
expect(new URL(requestUrl).search).toBe('')
})
it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => {
server.use(
http.post(
`${BASE}/suggestions/:id/accept`,
record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }),
),
)
const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`)
expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
expect(res.createdPlaceId).toBe(900)
})
it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => {
server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' })))
const res = await dawarichApi.setState(41, 'dismissed')
expect(requestUrl).toContain(`${BASE}/suggestions/41/state`)
expect(requestBody).toEqual({ state: 'dismissed' })
expect(res.state).toBe('dismissed')
})
})
describe('dawarichApi bucket list', () => {
it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => {
server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan)))
const res = await dawarichApi.scanBucketList()
expect(requestUrl).toContain(`${BASE}/bucket-list/scan`)
expect(res.skippedWithoutCoordinates).toBe(1)
})
// `visitedAt` is optional because the default is "whenever the recording says",
// which only the server knows. An omitted one must not turn into a null on the
// wire, which the server would read as an instruction to store no date at all.
it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => {
server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 })))
const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z')
expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' })
expect(res.updated).toBe(2)
await dawarichApi.confirmBucketVisits([3])
expect(requestBody).toEqual({ itemIds: [3] })
})
it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => {
server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true })))
expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true })
expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`)
})
})
describe('dawarichApi atlas', () => {
it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => {
server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas)))
const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31')
expect(query().get('from')).toBe('2026-01-01')
expect(query().get('to')).toBe('2026-12-31')
expect(res.unresolved).toEqual(['Kosovo'])
})
it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => {
server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 })))
const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL'])
expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] })
expect(res.marked).toBe(2)
})
})
describe('dawarichApi track overlay', () => {
it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => {
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' })
expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`)
expect(query().get('from')).toBe('2026-09-10')
expect(query().get('to')).toBe('2026-09-12')
expect(query().get('offset')).toBe('120')
expect(res.source).toBe('tracks')
})
// The whole-trip case passes no range at all. The offset still has to be there:
// without it the server has to guess a day boundary for every recorded instant.
it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => {
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
await dawarichApi.tripTrack(7)
expect([...query().keys()]).toEqual(['offset'])
expect(query().get('offset')).toBe('120')
})
it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => {
server.use(http.get(`${BASE}/track`, record(track)))
const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`)
expect(query().get('from')).toBe('2026-09-10')
expect(query().get('to')).toBe('2026-09-12')
expect(query().get('offset')).toBe('120')
expect(res.pointCount).toBe(0)
})
// The health handler is here because the axios error interceptor probes
// reachability whenever an error carries no response, and a cancellation is
// exactly that. Answering the probe keeps the detour short; whichever verdict
// it comes back with, the cancellation has to be re-rejected untouched, and
// that is what this pins.
it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => {
server.use(
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
http.get(`${BASE}/track`, async () => {
await delay(50)
return HttpResponse.json(track)
}),
)
const ctrl = new AbortController()
const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal)
ctrl.abort()
await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' })
})
// Nothing in this file catches, on purpose: the connection card and the
// suggestions hook both read the upstream reason off `response.data.code` to
// say it in the reader's language. A swallowed rejection would reach them as a
// successful call carrying an undefined body.
it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => {
server.use(
http.get(`${BASE}/suggestions`, () =>
HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }),
),
)
await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({
response: { status: 502, data: { code: 'unreachable' } },
})
})
// Every other test here runs in Berlin, where -120 negated and -120 made
// positive look identical. New York separates them: the platform reports +300
// and the wire has to carry -300. A `Math.abs` slipped in would leave every
// European install working perfectly and put every American one a day out,
// which is the kind of bug that gets reported as "sometimes wrong".
//
// UTC is the second half of the pair: the offset there is falsy, and a guard
// that only sends it "when there is one" would drop it exactly for the readers
// whose day boundary the server would then have to guess.
it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => {
server.use(http.get(`${BASE}/track`, record(track)))
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(query().get('offset')).toBe('-300')
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0)
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(query().get('offset')).toBe('0')
})
})
describe('dawarichApi: how long the browser waits', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
// The server gives each request to Dawarich 15 s on its own. At the shared
// 8 s the browser gave up first, the server cached the answer anyway, and the
// recorded route turned up only after a reload.
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
await dawarichApi.tripTrack(7)
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
await dawarichApi.test({ url: 'https://dawarich.example' })
await dawarichApi.syncNow()
await dawarichApi.scanBucketList()
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
const configs = [
...get.mock.calls.map(call => call[1]),
...post.mock.calls.map(call => call[2]),
...put.mock.calls.map(call => call[2]),
]
expect(configs).toHaveLength(6)
for (const config of configs) {
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
}
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
// The track calls still carry their offset alongside the longer wait.
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
})
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
// Reading suggestions and confirming countries touch TREK's own database
// only; a two-minute wait there would just hide a stuck server.
await dawarichApi.listSuggestions()
await dawarichApi.acceptAtlasCountries(['DE'])
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
expect(post.mock.calls[0][2]).toBeUndefined()
})
})
+107
View File
@@ -0,0 +1,107 @@
import apiClient from './client'
import type {
DawarichAccept,
DawarichAcceptResult,
DawarichAtlasSuggestions,
DawarichBucketScan,
DawarichConnection,
DawarichSettings,
DawarichStatus,
DawarichSuggestion,
DawarichSuggestionList,
DawarichTrack,
} from '@trek/shared'
/**
* The Dawarich surface, in its own file rather than in the `client.ts` god
* module. Types come from the shared contracts, so a server change that the
* client has not caught up with is a typecheck failure rather than a runtime
* surprise.
*/
/**
* How long the browser waits for a call that makes the server go and ask
* Dawarich.
*
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
* these: the server allows each request to Dawarich 15 s by itself, walks pages
* of points for a route, and runs six probes one after another to test a
* connection. Cut off at 8 s, the browser gave up while the server carried on
* and cached the answer, so the recorded route showed "unavailable" and then
* appeared on the next reload.
*/
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
/**
* The Atlas asks for a year of visited cities a month at a time, one request
* after another, so it gets longer again. Still bounded, and under Node's own
* five-minute request limit on the server side.
*/
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
export const dawarichApi = {
// ── Connection ─────────────────────────────────────────────────────────────
getSettings: (): Promise<DawarichConnection> =>
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
saveSettings: (data: DawarichSettings): Promise<{
success: boolean
/** The server's own sentence, English — the fallback when the code below is unknown here. */
warning?: string
/** What the warning is about, so the UI can say it in the reader's language. */
warningCode?: string
/** The address it resolved to, for the private-IP warning. */
warningIp?: string
}> =>
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
disconnect: (): Promise<{ success: boolean }> =>
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
// ── Suggestions ────────────────────────────────────────────────────────────
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
// ── Bucket list ────────────────────────────────────────────────────────────
scanBucketList: (): Promise<DawarichBucketScan> =>
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
// ── Atlas ──────────────────────────────────────────────────────────────────
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
apiClient
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
.then(r => r.data),
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
// ── Track overlay ──────────────────────────────────────────────────────────
//
// Recorded points are bare instants, so the server cannot know which day they
// belong to without being told whose day is meant. The browser is the only
// party that knows, so it says so — otherwise an evening east of UTC lands on
// the wrong day of the trip.
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
}
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
function localOffsetMinutes(): number {
return -new Date().getTimezoneOffset()
}
+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.
})
}
+24
View File
@@ -0,0 +1,24 @@
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
import { apiClient } from './client'
/**
* Reports the routing counters this browser has tallied up.
*
* Fire and forget: it never throws and never blocks the route it is describing. A
* failed report is a lost count, which is a far smaller problem than a map that waits
* on a measurement.
*
* No client-side switch to check, unlike the shadow log. Counting is on unless an
* operator turned it off, and that is a server setting the server enforces — a report
* to a switched-off instance is answered with `recorded: false` and costs one request.
*/
export const routeUsageApi = {
report: (entries: RouteUsageEntry[]): Promise<void> =>
entries.length
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
: Promise.resolve(),
/** Admin only. The figures behind "could we host a router ourselves". */
summary: (): Promise<RouteUsageSummaryResult> =>
apiClient.get('/route-usage/summary').then(r => r.data),
}
+13
View File
@@ -0,0 +1,13 @@
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
import apiClient from './client'
const base = '/school-holiday-catalog'
export const schoolHolidayCatalogApi = {
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
}
+22
View File
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'collab:note:created',
'collab:note:updated',
'collab:note:deleted',
'collab:link:created',
'collab:link:updated',
'collab:link:deleted',
'collab:poll:created',
'collab:poll:voted',
'collab:poll:closed',
@@ -27,9 +30,22 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
// routed through live in that hook and nowhere else; a store slice for them would be a
// second copy of the same list to keep in step.
'roadtripVia:changed',
'roadtripTrack:changed',
'roadtripPreferences:changed',
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
'roadtripBoundary:changed',
// 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',
@@ -65,6 +81,12 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Document sync: components/Files/docsync/useDocSync re-reads the bindings
// and their counts, and components/Files/docsync/useDocSyncOffered asks
// again whether the Files screen offers the sync button. Content-free like
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+228 -47
View File
@@ -1,5 +1,5 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -44,20 +44,16 @@ function modelsRoute(names: string[], seen?: (string | null)[]) {
});
}
/** The pill toggle of a top-level addon row. */
/** Every switch is labelled with the thing it controls, top-level tile or shelf
* row alike, so one lookup covers both. */
function addonToggle(name: string): HTMLElement {
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
return within(row).getByRole('button');
return screen.getByRole('button', { name });
}
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
function subToggle(label: string): HTMLElement {
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
return within(row).getByRole('button');
}
const subToggle = addonToggle;
function isOn(toggle: HTMLElement): boolean {
return toggle.style.background === 'var(--text-primary)';
return toggle.getAttribute('aria-pressed') === 'true';
}
beforeAll(() => {
@@ -82,6 +78,7 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks();
document.documentElement.classList.remove('dark');
});
describe('AddonManager', () => {
@@ -144,13 +141,10 @@ describe('AddonManager', () => {
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
// Get toggle button - use getAllByRole since there might be multiple buttons
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
expect(toggleBtn).toBeInTheDocument();
const toggleBtn = addonToggle('Todo List');
expect(isOn(toggleBtn)).toBe(false);
// Before click - disabled state (border-primary bg)
await user.click(toggleBtn!);
await user.click(toggleBtn);
// After click - success toast
await screen.findByText('Addon updated');
@@ -169,17 +163,14 @@ describe('AddonManager', () => {
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
await user.click(toggleBtn!);
await user.click(addonToggle('Todo List'));
// Error toast appears
await screen.findByText('Failed to update addon');
// The disabled text should be back after rollback
// The switch is back off after rollback
await waitFor(() => {
const disabledTexts = screen.getAllByText('Disabled');
expect(disabledTexts.length).toBeGreaterThan(0);
expect(isOn(addonToggle('Todo List'))).toBe(false);
});
});
@@ -195,13 +186,7 @@ describe('AddonManager', () => {
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
);
await screen.findByText('Bag Tracking');
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
// There should be two toggle buttons: one for the addon, one for bag tracking
await user.click(allBtns[allBtns.length - 1]);
await user.click(subToggle('Bag Tracking'));
expect(mockToggle).toHaveBeenCalled();
});
@@ -251,9 +236,10 @@ describe('AddonManager', () => {
// Journey addon is rendered
expect(screen.getByText('Journey')).toBeInTheDocument();
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
expect(toggleBtns.length).toBe(3);
// Journey's own switch plus one per provider, each labelled with what it controls
expect(isOn(addonToggle('Journey'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(true);
expect(isOn(subToggle('Pexels'))).toBe(false);
});
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
@@ -278,7 +264,8 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
// The wordmark reads the .dark class, the same source applyAppearance() writes.
document.documentElement.classList.add('dark');
render(<AddonManager />);
await screen.findByText('No addons available');
@@ -309,11 +296,11 @@ describe('AddonManager', () => {
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
@@ -361,7 +348,172 @@ describe('AddonManager', () => {
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
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-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
expect(isOn(subToggle('Paperless-ngx'))).toBe(true);
expect(isOn(subToggle('Nextcloud'))).toBe(false);
// Documents and Journey are the addons; an enabled provider is a shelf row
// and must not turn 1/2 into 2/2.
expect(screen.getByText('1/2')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
]));
render(<AddonManager />);
await screen.findByText('Documents');
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;
let documentsOn = true;
let paperlessOn = true;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }),
],
});
}),
http.put('/api/admin/addons/documents', async ({ request }) => {
documentsOn = (await request.json() as { enabled: boolean }).enabled;
if (!documentsOn) paperlessOn = false;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Paperless-ngx');
await user.click(addonToggle('Documents'));
await screen.findByText('Addon updated');
expect(loads).toBe(2);
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
await user.click(addonToggle('Documents'));
await screen.findByText('Paperless-ngx');
expect(isOn(subToggle('Paperless-ngx'))).toBe(false);
});
it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => {
const user = userEvent.setup();
let loads = 0;
let body: unknown = null;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }),
],
});
}),
http.put('/api/admin/addons/papra', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Papra');
await user.click(subToggle('Papra'));
await screen.findByText('Addon updated');
expect(body).toEqual({ enabled: true });
expect(loads).toBe(1);
expect(isOn(subToggle('Papra'))).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();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
@@ -391,19 +543,48 @@ describe('AddonManager', () => {
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
// The row's tooltip is its description. The links tab's empty state read as a
// status here, and a status that never changes reads as a broken feature.
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
);
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
expect(links.getAttribute('title')).not.toBe('No shared links yet');
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
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 () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
@@ -413,9 +594,9 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -475,7 +656,7 @@ describe('AddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -495,8 +676,8 @@ describe('AddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
@@ -615,7 +796,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');
+321 -354
View File
@@ -1,14 +1,26 @@
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 DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
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,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -30,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
// The document providers' own marks, single-colour so they read as glyphs in
// a row of lucide icons and invert with the theme.
...DOCUMENT_PROVIDER_ICONS,
}
interface Addon {
@@ -53,89 +68,100 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
// The brand marks are badges, not glyphs: they fill the framed slot instead of
// sitting in the middle of it at glyph size, and they grey out when the addon
// is off — the lucide glyphs get that from the slot's text colour, an <img>
// has to be told.
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
/** 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; links?: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
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.
// Same for Documents: switching it off disables every document provider in
// the database, and the response carries only the addon itself.
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
const isPhotoProviderAddon = (addon: Addon) => {
return addon.type === 'photo_provider'
}
const isDocumentProviderAddon = (addon: Addon) => {
return addon.type === 'document_provider'
}
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
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 documentProviderAddons = addons.filter(isDocumentProviderAddon)
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 +170,186 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
toggle: () => handleToggle(provider),
}))
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
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)}
/>
)
})
}
// Document providers are the Documents tile's shelf, the way photo providers
// are Journey's. Unlike those they carry no credential form here: a document
// connection belongs to a trip, not to a user, so it is entered in the trip's
// file manager. The admin decides only whether a provider may be offered.
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
return documentProviderOptions.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}
/>
)
})
}
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} enabled={addon.enabled} />}
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.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
<div className="space-y-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<div>
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
{t('admin.addons.subtitleBefore')}
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm text-content-faint">
{t('admin.addons.noAddons')}
</div>
) : (
<div>
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Briefcase size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button" onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Globe size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button"
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Link2 size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))}
</div>
)}
</div>
{totalCount > 0 && (
<span
className="shrink-0 text-caption tabular-nums text-content-faint"
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
>
{enabledCount}/{totalCount}
</span>
)}
</div>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
) : (
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key}>
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="space-y-3">{g.addons.map(tile)}</div>
</section>
)
})}
</div>
)}
</div>
)
}
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string }>
label: string
hint: string
addons: Addon[]
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<div
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
title={t('admin.addons.group.count', { enabled, total: addons.length })}
>
<div className="flex items-center gap-2">
<Icon size={13} className="shrink-0 text-content-muted" />
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
{enabled}/{addons.length}
</span>
</div>
{/* Says what the type means — the only place that still explains it. */}
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
</div>
)
}
@@ -322,7 +361,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
]
/**
@@ -408,9 +447,8 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
@@ -418,131 +456,113 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
sized and single-column — the band this used to be had a whole page width. */
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
<li className="space-y-2.5 py-1.5">
<p className="text-caption text-content-faint">
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
<label className="block">
<span className={labelCls}>Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
<div className="flex items-center justify-between">
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<span className="sr-only">Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
))}
</div>
)}
</section>
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
<div className="border-t border-edge-secondary pt-2">
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
{m.recommended && (
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
)}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-1">
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</li>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
t: (key: string) => string
statusOverride?: boolean
hideToggle?: boolean
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`
const descKey = `admin.addons.catalog.${addon.id}.description`
@@ -555,56 +575,3 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
}
}
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
const displayName = nameOverride || label.name
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-content">{displayName}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button type="button"
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
style={{
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
/**
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
* Lists, a collab feature under Collab, a photo provider under Journey.
*
* A photo provider without a vendor mark has to stay free of `<svg>`
* (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer
* rather than a placeholder glyph. Nothing else belongs in this row either: no
* chevron, no status dot, no lucide fallback.
*
* The row carries its title only; the description rides along as the `title`
* tooltip, because a second line of text is what makes a child weigh as much as
* an addon.
*/
export default function AddonSubRow({
icon,
title,
description,
enabled,
onToggle,
}: {
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
icon?: ReactNode
title: string
description?: string
enabled: boolean
onToggle: () => void
}) {
return (
<li className="flex min-h-[36px] items-center gap-3">
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
{title}
</span>
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
</li>
)
}
+82
View File
@@ -0,0 +1,82 @@
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
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
}`}
>
{icon}
</div>
<div className="min-w-0 flex-1">
{/* The name stays full-strength when off: it is the tile's identity, not its
state. Greying it out reads as "broken" rather than "not enabled". */}
<h4 className="truncate text-body font-semibold text-content" title={name}>
{name}
</h4>
<p
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
title={description}
>
{description}
</p>
</div>
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
</div>
{children && (
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
{/* Sitting inside the parent on its own fill is enough to say these belong
to it — no rail, no indent guides. */}
<ul className="space-y-0.5">{children}</ul>
</div>
)}
</article>
)
}
@@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
*/
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
// operatorEgress so the menu shows every action, Allowed hosts included (it is
// gated on the declaration, like the row chip).
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true })
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
@@ -281,6 +283,10 @@ describe('AdminPluginsPanel — a refused update', () => {
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
})
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
@@ -843,6 +849,30 @@ describe('AdminPluginsPanel — row actions', () => {
expect(screen.getByText('View error log')).toBeInTheDocument()
expect(screen.queryByText('Restart')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
// An admin must never be invited to widen egress for a plugin that didn't ask for it
// — same rule the row chip already follows.
await openRowMenu(plugin({ operatorEgress: false }))
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
await openRowMenu()
fireEvent.click(screen.getByText('Allowed hosts'))
// The card is capped and its body scrolls, so the input, the Add button and the
// restart note below the list stay reachable however many hosts the admin entered.
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
})
})
describe('AdminPluginsPanel — enabling a plugin', () => {
@@ -947,7 +977,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
await screen.findByText('Gotify')
fireEvent.click(rowToggle('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i }))
// The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
expect(installs).toBe(0)
@@ -967,6 +1001,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
// An unknown permission is still shown — as its raw code
expect(screen.getByText('weird:perm')).toBeInTheDocument()
expect(screen.getByText('api.acme.io')).toBeInTheDocument()
// A long permission list scrolls inside the dialog; the decision buttons sit outside
// the scroll container, so they never leave the viewport (#2159).
const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
@@ -1439,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the card shifted the Install button out from under the pointer, so the click
// retargeted onto the card and opened the detail modal instead of installing.
// The data-no-press attribute is the pin.
discoverWith({})
render(<AdminPluginsPanel />)
await clickDiscover()
expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
})
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
discoverWith(
{ downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
@@ -1763,7 +1814,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
const user = userEvent.setup()
mockPanel([row()])
// Declares operatorEgress (so the menu item shows), but the runtime lookup
// disagrees — the dialog's unsupported notice is the truth-teller.
mockPanel([row({ operatorEgress: true })])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
)
@@ -1778,7 +1831,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
const user = userEvent.setup()
mockPanel([row()])
mockPanel([row({ operatorEgress: true })])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
@@ -2134,3 +2187,357 @@ describe('AdminPluginsPanel — update all', () => {
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
})
})
/**
* The admin-owned `scope:'instance'` settings form. The fields are declared manifest
* data (validated at install), the values live in the plugin's instance config, and a
* save may RESTART a running plugin — the child reads config once, at init — so the
* admin must be told when that happened rather than wondering why the plugin blinked.
*/
describe('AdminPluginsPanel — instance settings', () => {
const FIELDS = [
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
]
async function openRowMenu(p: Record<string, unknown>) {
mockList(p)
render(<><ToastContainer /><AdminPluginsPanel /></>)
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
}
async function openSettings(config: Record<string, unknown> = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })),
)
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
fireEvent.click(screen.getByText('Instance settings'))
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
}
it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => {
await openSettings()
// Both declared fields render, the secret as a masked password input.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password')
})
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
)
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
expect(screen.getByText('Instance settings')).toBeInTheDocument()
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json({ config: body, restarted: false })
}),
)
await openSettings()
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
// The mask is a display artifact, not a value — sending it would overwrite the stored secret.
expect(body).toEqual({ apiUrl: 'https://new.example' })
})
it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
)
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => {
const RICH = [
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
]
let body: Record<string, unknown> | null = null
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json({ config: body, restarted: false })
}),
)
await openRowMenu(plugin({ instanceSettingsCount: 3 }))
fireEvent.click(screen.getByText('Instance settings'))
// Stored values land in the right controls; an unset value renders empty.
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
expect(select).toHaveValue('slow')
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('spinbutton')).toHaveValue(null)
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
fireEvent.change(select, { target: { value: 'fast' } })
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
})
const ACTIONS = [
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
]
async function openWithActions(p: Record<string, unknown> = plugin({ instanceSettingsCount: 2 })) {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
)
await openRowMenu(p)
fireEvent.click(screen.getByText('Instance settings'))
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
}
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
let posted = ''
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
posted = String(params.key)
return HttpResponse.json({ ok: true, message: 'pong' })
}),
)
await openWithActions()
expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
expect(posted).toBe('ping')
// The dialog stays open — the admin may run another one.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
let posted = ''
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
posted = String(params.key)
return HttpResponse.json({ ok: false, message: 'cache locked' })
}),
)
await openWithActions()
fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
expect(await screen.findByText('Run this action?')).toBeInTheDocument()
expect(posted).toBe('')
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
expect(posted).toBe('purge')
})
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
const calls: string[] = []
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
)
await openWithActions()
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
})
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
)
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument()
// Still open — the admin's edits are not thrown away on a refusal.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => {
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }],
config: {},
})),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }],
config: { oauth_authorize_url: 'https://mine.example' },
})),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument()
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => {
let called = false
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
config: {},
})),
http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
await screen.findByText('Client ID', {}, { timeout: 5000 })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
expect(called).toBe(false)
})
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
await openSettings()
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
// the near-white accent the dark theme uses by default.
const save = screen.getByRole('button', { name: /^save$/i })
expect(save.className).toContain('text-accent-text')
expect(save.className).not.toContain('text-white')
})
})
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
* panel's job is to make sure the admin actually SEES the warning — before a registry
* install, after a sideload/link that could not be asked first, and on the row for as
* long as the plugin keeps running outside the range its author vouched for.
*/
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
)
}
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
it('FE-PLG-BYPASS-001: says so in the header', async () => {
bypassPanel([plugin()], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
posted = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
}))
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
})
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
let posted = false
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
expect(posted).toBe(false)
})
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
bypassPanel([plugin()], null)
server.use(http.post('*/api/admin/plugins/upload', () =>
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
const { container } = withToast()
await screen.findByText('Gotify')
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
})
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
bypassPanel([plugin({
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
})], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
})
})
+250 -42
View File
@@ -7,9 +7,12 @@ import {
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot,
Loader2,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { adminApi } from '../../api/client'
import { useInstanceSettings } from './useInstanceSettings'
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -49,6 +52,12 @@ interface PluginRow {
operatorEgress?: boolean
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
egressHostCount?: number
/** How many `scope:'instance'` settings fields the plugin declares — gates the
* "Instance settings" menu item without a per-plugin fetch. */
instanceSettingsCount?: number
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
* but no settings fields still needs the menu item to run them. */
instanceActionsCount?: number
dependencies?: PluginDependencies
dependencyStatus?: DependencyStatus
dependencyIssues?: DependencyIssues
@@ -56,6 +65,8 @@ interface PluginRow {
trekRange?: string | null
/** The TREK this server is running — the server does the semver, the client just shows it. */
hostVersion?: string
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
trekRangeBypassed?: TrekRangeBypass | null
/** The author's signature was verified and their key pinned at install. False means the
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
signed?: boolean
@@ -251,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
@@ -265,7 +277,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
return out
}
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
// A plugin's declared dependencies as chips — a required addon (amber when that
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
@@ -283,6 +295,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
blocked: true,
})
}
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
if (bypassed) out.push(bypassed)
for (const a of p.dependencies?.requiredAddons ?? []) {
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
}
@@ -302,11 +316,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
* grey button. The plugin is perfectly usable, just not at its newest.
*/
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
const title = item.trek
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown')
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
if (item.latestCompatible) {
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
}
@@ -390,6 +405,8 @@ export default function AdminPluginsPanel() {
const toast = useToast()
const [runtimeOn, setRuntimeOn] = useState(false)
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
const [linkPath, setLinkPath] = useState('')
const [plugins, setPlugins] = useState<PluginRow[]>([])
const [loading, setLoading] = useState(true)
@@ -408,6 +425,8 @@ export default function AdminPluginsPanel() {
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
// The admin-owned scope:'instance' settings form — shared logic with the phone shell.
const settings = useInstanceSettings()
const [egressDraft, setEgressDraft] = useState('')
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
@@ -462,9 +481,10 @@ export default function AdminPluginsPanel() {
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
void usePluginStore.getState().loadPlugins()
adminApi.plugins()
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
setRuntimeOn(!!d.enabled)
setDevLink(!!d.devLink)
setIgnoreTrekRange(!!d.ignoreTrekRange)
setPlugins(d.plugins || [])
if ((d.plugins || []).length) {
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
@@ -530,6 +550,7 @@ export default function AdminPluginsPanel() {
const res = await adminApi.pluginUpload(file)
setView('installed')
toast.success(t('admin.plugins.uploaded', { name: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -573,6 +594,11 @@ export default function AdminPluginsPanel() {
}
}
const openInstanceSettings = (p: { id: string; status: string }) => {
setMenu(null)
settings.open(p.id, p.status === 'active')
}
const openErrors = (id: string) => {
setMenu(null)
adminApi.pluginErrors(id)
@@ -595,7 +621,11 @@ export default function AdminPluginsPanel() {
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
// `warn` is the pre-install confirm for an entry the registry already flagged as
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
// was only a pre-download filter) is caught by the marker on the response instead.
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
@@ -608,6 +638,7 @@ export default function AdminPluginsPanel() {
setView('installed')
setLinkPath('')
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -667,9 +698,10 @@ export default function AdminPluginsPanel() {
if (busy === parent.id) return
setBusy(parent.id)
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
bypass.notice(depId, r?.trekRangeBypassed)
return attemptActivate(parent)
})
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
@@ -687,9 +719,10 @@ export default function AdminPluginsPanel() {
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
return adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
bypass.notice(p.name, r.trekRangeBypassed)
})
.catch(e => {
const { error, code } = errBody(e)
@@ -813,11 +846,19 @@ export default function AdminPluginsPanel() {
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
</div>
{runtimeOn && (
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
<div className="flex items-center gap-2 shrink-0">
{runtimeOn && ignoreTrekRange && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
title={t('admin.plugins.rangeBypass.pillHint')}>
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
</span>
)}
{runtimeOn && (
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
</div>
</div>
</div>
@@ -927,7 +968,7 @@ export default function AdminPluginsPanel() {
) : error ? (
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
) : !runtimeOn ? null : view === 'discover' ? (
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
) : plugins.length === 0 ? (
<EmptyState t={t} onDiscover={openDiscover} />
@@ -962,6 +1003,7 @@ export default function AdminPluginsPanel() {
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
})}
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
onSettings={() => openInstanceSettings(p)}
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
))}
</div>
@@ -972,10 +1014,12 @@ export default function AdminPluginsPanel() {
{/* Registry detail dialog */}
{detailFor && (
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
)}
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
{/* Error-log modal */}
{errorsFor && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
@@ -1001,12 +1045,12 @@ export default function AdminPluginsPanel() {
{/* Operator-supplied egress hosts */}
{egressFor && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[88vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
<div className="p-5 space-y-3 min-h-0 flex-1 overflow-y-auto">
{!egressFor.supported ? (
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
) : (
@@ -1048,6 +1092,110 @@ export default function AdminPluginsPanel() {
</div>
)}
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
{settings.form && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={settings.close}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[80vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>
<button type="button" aria-label={t('common.close')} onClick={settings.close} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-4 overflow-y-auto">
{settings.form.fields.map(f => (
<label key={f.key} className="block">
<span className="block text-sm font-medium text-content-secondary mb-1">
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
</span>
{f.input_type === 'checkbox' ? (
<input
type="checkbox"
checked={settings.form.values[f.key] === true}
onChange={e => settings.setValue(f.key, e.target.checked)}
className="h-4 w-4 rounded border-edge"
/>
) : f.input_type === 'select' && f.options ? (
<select
value={String(settings.form.values[f.key] ?? '')}
onChange={e => settings.setValue(f.key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
>
<option value="">—</option>
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : (
<input
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
value={String(settings.form.values[f.key] ?? '')}
placeholder={f.placeholder || ''}
autoComplete={f.secret ? 'new-password' : 'off'}
onChange={e => settings.setValue(f.key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
/>
)}
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
</label>
))}
{(() => {
const form = settings.form
if (!form || form.actions.length === 0) return null
return (
<div className="border-t border-edge-secondary pt-4">
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
{t('admin.plugins.actions')}
</span>
{!form.active && (
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
)}
<div className="flex flex-col gap-2">
{form.actions.map(a => {
const res = settings.actionResult[a.key]
return (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<button type="button"
onClick={() => settings.runAction(a)}
disabled={!form.active || settings.runningAction !== null || settings.saving}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
}`}
>
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
{a.label}
</button>
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
{res && (
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
{res.message || (res.ok ? t('common.success') : t('common.error'))}
</span>
)}
</div>
)
})}
</div>
</div>
)
})()}
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
<button type="button"
disabled={settings.saving || settings.runningAction !== null}
onClick={() => void settings.save()}
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
>{t('common.save')}</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={settings.pendingAction !== null}
onClose={settings.cancelPendingAction}
onConfirm={settings.confirmPendingAction}
title={settings.pendingAction?.label ?? ''}
message={t('admin.plugins.actions.confirm')}
confirmLabel={t('common.confirm')}
/>
<ConfirmDialog
isOpen={!!confirmUninstall}
onClose={() => setConfirmUninstall(null)}
@@ -1185,11 +1333,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onUninstall: () => void
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
@@ -1304,8 +1452,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
{deps.map((d, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
</span>
))}
</div>
@@ -1334,8 +1482,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
never be invited to widen egress for a plugin that didn't ask for it
(same rule the row's egress chip follows). */}
{p.operatorEgress && (
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
@@ -1405,14 +1563,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
)
}
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
items: RegistryItem[] | null
onInstall: (id: string, version?: string) => void
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
onOpenDetail: (item: RegistryItem) => void
busy: string | null
t: T
installedIds: Set<string>
filtered: boolean
ignoreTrekRange: boolean
}) {
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
if (items.length === 0) return (
@@ -1425,9 +1584,13 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
{items.map(item => {
const installed = installedIds.has(item.id)
const offer = installOffer(item, t)
const offer = installOffer(item, t, ignoreTrekRange)
return (
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
// No press-scale on the card: shrinking it mid-click slides the Install
// button out from under the pointer, so the click retargets onto the card
// and opens the detail modal instead of installing (#2158).
data-no-press
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
<div className="relative">
@@ -1456,7 +1619,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<Download size={11} /> {formatCompactCount(item.downloadCount)}
</span>
)}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
@@ -1486,9 +1649,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
item: RegistryItem; installed: boolean; busy: string | null
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
}) {
return createPortal(
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="dialog" aria-modal="true" aria-label={title}
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
<AlertTriangle size={20} className="text-warning" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">{title}</h3>
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
{confirm ? (
<>
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
{t('common.cancel')}
</button>
<button type="button" onClick={onConfirm}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
{t('admin.plugins.installAnyway')}
</button>
</>
) : (
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
{t('common.ok')}
</button>
)}
</div>
</div>
</div>,
document.body,
)
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const [detail, setDetail] = useState<RegistryDetail | null>(null)
const [failed, setFailed] = useState(false)
@@ -1511,7 +1719,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
// The detail fetch carries the same compat verdict as the browse list; prefer it once
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
const offer = installOffer(detail ?? item, t)
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
@@ -1533,7 +1741,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
<button type="button" onClick={() => onInstall(item.id, offer.version)}
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
@@ -1793,15 +2001,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
</div>
</div>
<div className="p-5 space-y-4">
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
new one back to the author over the phone. The full key travels in the request. */}
{canRetrust && (
@@ -1815,7 +2023,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onClose}
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
@@ -1857,15 +2065,15 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
}) {
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
</div>
</div>
<div className="p-5 space-y-4">
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* The admin is about to widen what this code may do — so say, right here, that
nothing ties this code to its author. One line, no checkbox, no extra click:
this informs, it does not block. */}
@@ -1894,7 +2102,7 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
</div>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
</div>
@@ -1917,15 +2125,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
]
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
</div>
</div>
<div className="p-5 space-y-2.5">
<div className="p-5 space-y-2.5 min-h-0 flex-1 overflow-y-auto">
{rows.map(r => (
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
<div className="min-w-0 flex-1">
@@ -1946,7 +2154,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
</div>
</div>
@@ -192,7 +192,7 @@ export default function CategoryManager() {
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
</div>
<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">
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Settings2 } from 'lucide-react'
import { Map as MapIcon, Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -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,
@@ -19,6 +19,7 @@ import {
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = {
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
@@ -219,11 +220,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
<div>
<p className="text-sm text-content-faint mb-4">
{t('admin.defaultSettings.description')}
</p>
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
the map fields sat a screen below the units while the right half of the page
stayed empty. Grouped by subject rather than by height — what a user ends up
seeing on the left, everything that configures the map on the right.
The columns are deliberately uneven: the left one never needs more than the
widest option row, while the right one holds four text fields whose values
are 60-character tile URLs. Only the column gap is set, because Section
carries its own bottom margin and a row gap would double it once the grid
collapses. Two cards can sit straight in the grid; a third would need the
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
{/* Color Mode */}
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
{([
@@ -321,7 +334,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</Section>
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
@@ -365,6 +380,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{!managed && (
<div className="mt-3.5">
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
</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, {
@@ -378,7 +398,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,
@@ -488,6 +510,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</div>
)}
</div>
</Section>
</Section>
</div>
</div>
)
}
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<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>
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
</div>
<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">
@@ -0,0 +1,64 @@
import { useId, useState } from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
export interface RoutingDefaults {
routing_base_url?: string
valhalla_base_url?: string
}
interface RoutingFieldsProps {
defaults: RoutingDefaults
onSave: (patch: RoutingDefaults) => Promise<void>
onReset: (key: keyof RoutingDefaults) => Promise<void>
hintClassName?: string
}
const ROUTERS = [
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
] as const
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
const { t } = useTranslation()
const id = useId()
const [draft, setDraft] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const saved = defaults[router.key] ?? ''
const commit = async () => {
if (draft === null) return
const value = draft.trim()
if (value === saved) { setDraft(null); return }
setSaving(true)
try { await onSave({ [router.key]: value }) }
finally { setDraft(null); setSaving(false) }
}
const reset = async () => {
setSaving(true)
try { await onReset(router.key) }
finally { setDraft(null); setSaving(false) }
}
return (
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
{defaults[router.key] !== undefined && (
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
)}
</div>
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
onChange={e => setDraft(e.target.value)} onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
aria-describedby={`${id}-hint`}
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
/>
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
</div>
)
}
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { within } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores } from '../../../tests/helpers/store'
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
import { useVacayStore } from '../../store/vacayStore'
import type { SchoolHolidayRegionDetail } from '@trek/shared'
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
beforeEach(() => resetAllStores())
function enterDate(label: string, value: string) {
const picker = within(screen.getByRole('group', { name: label }))
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
const input = picker.getByRole('textbox')
fireEvent.change(input, { target: { value } })
fireEvent.blur(input)
}
describe('manual school holiday administration', () => {
it('creates a country, a district and its named periods', async () => {
let created = false
const saves: unknown[] = []
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
)
render(<SchoolHolidayCatalog />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
enterDate('Start date', '2026-12-20')
enterDate('End date', '2027-01-06')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
})
it('opens existing periods and keeps edits after a failed save', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
await screen.findByDisplayValue('Winter break')
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
})
it('validates names and confirms discarding changes', async () => {
const save = vi.fn()
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
expect(close).not.toHaveBeenCalled()
})
it('removes a period and saves an existing region without changing its revision token', () => {
const save = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
})
it('blocks offline edits but still lets the dialog close', () => {
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(close).toHaveBeenCalledOnce()
})
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
}),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
})
it('keeps national API calendars selectable alongside manual regions', async () => {
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
})
it('keeps API regions available if loading the manual catalog fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
})
it('offers USA independently of provider countries and fetches only local regions', async () => {
const provider = vi.fn(() => HttpResponse.json([]))
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
render(<Countries />)
expect(await screen.findByText('USA')).toBeInTheDocument()
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
expect(provider).not.toHaveBeenCalled()
})
it('keeps manual regions usable when a supported country provider fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
})
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
useVacayStore.setState({
selectedYear: 2026,
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
})
const entries = useVacayStore.getState().entries
await useVacayStore.getState().loadHolidays(2026)
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
expect(useVacayStore.getState().entries).toBe(entries)
})
});
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
import ConfirmDialog from '../shared/ConfirmDialog'
import ErrorBoundary from '../shared/ErrorBoundary'
import CustomSelect from '../shared/CustomSelect'
export default function SchoolHolidayCatalog() {
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
}
function Catalog() {
const { t } = useTranslation()
const state = useSchoolHolidayCatalog()
const [country, setCountry] = useState('')
const [adding, setAdding] = useState(false)
const [code, setCode] = useState('')
const [name, setName] = useState('')
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
const disabled = state.busy || state.loading || state.offline
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
<div>
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
</div>
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
<fieldset disabled={disabled} className="space-y-4">
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
</div>
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
</div>}
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
event.preventDefault()
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
}}>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
</form>}
{selectedCountry && <>
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
</li>)}</ul>
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
</>}
</fieldset>
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
</section>
}
@@ -0,0 +1,75 @@
import { useId, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
region: SchoolHolidayRegionDetail
busy: boolean
offline?: boolean
error: string
onClose: () => void
onSave: (body: SchoolHolidayRegionRequest) => void
}) {
const { t } = useTranslation()
const fieldId = useId()
const [name, setName] = useState(region.name)
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
const [nextKey, setNextKey] = useState(holidays.length)
const [discard, setDiscard] = useState(false)
const [invalid, setInvalid] = useState(false)
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
return <>
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
<form className="space-y-4" onSubmit={event => {
event.preventDefault()
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
setInvalid(!parsed.success)
if (parsed.success) onSave(parsed.data)
}}>
<fieldset disabled={busy || offline} className="space-y-4">
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
</label>
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
<div className="space-y-1 text-body">
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
<div className="flex items-stretch gap-2">
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
</div>
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
</div>
</div>
</div>)}
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
{error && <p role="alert" className="text-body text-danger">{error}</p>}
</fieldset>
<div className="flex justify-end gap-2 border-t border-edge pt-4">
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
</div>
</form>
</Modal>
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
</>
}
@@ -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,
}
}
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import { useTranslation } from '../../i18n'
import { useNetworkMode } from '../../hooks/useNetworkMode'
export function useSchoolHolidayCatalog() {
const { t } = useTranslation()
const { offline } = useNetworkMode()
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
const mounted = useRef(true)
const pending = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const reload = useCallback(async () => {
catalogRepo.requireOnline()
const catalog = await catalogRepo.catalog()
if (mounted.current) setCatalog(catalog)
}, [])
useEffect(() => {
let cancelled = false
if (offline) { setLoading(false); return }
setLoading(true)
catalogRepo.catalog().then(catalog => {
if (!cancelled) { setCatalog(catalog); setError('') }
}).catch(() => { if (!cancelled) setError(t('common.error')) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [offline, t])
async function run(action: () => Promise<unknown>, after?: () => void) {
if (pending.current) return false
pending.current = true
setBusy(true)
setError('')
try {
catalogRepo.requireOnline()
await action()
if (mounted.current) after?.()
try {
await reload()
} catch {
if (mounted.current) setError(t('schoolCatalog.refreshError'))
}
return true
} catch (failure) {
if (mounted.current) {
const message = failure instanceof Error ? failure.message : ''
const http = failure as { response?: { data?: { error?: string } } }
setError(http.response?.data?.error || message || t('common.error'))
}
return false
} finally {
pending.current = false
if (mounted.current) setBusy(false)
}
}
return {
catalog, loading, busy, error, offline, editor, setEditor,
refresh: () => run(async () => {}),
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
openRegion: (id: number) => run(async () => {
const region = await catalogRepo.region(id)
if (mounted.current) setEditor(region)
}),
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
() => setEditor(null),
),
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
}
}
@@ -0,0 +1,48 @@
import { useRef, type RefObject } from 'react'
export interface GlassGlare {
/** Goes on the panel itself — the glare is measured against its box. */
panelRef: RefObject<HTMLDivElement | null>
/** The soft light under the cursor. */
glareRef: RefObject<HTMLDivElement | null>
/** The lit stretch of border around it. */
borderGlareRef: RefObject<HTMLDivElement | null>
onMouseMove: (e: { clientX: number; clientY: number }) => void
onMouseLeave: () => void
}
/**
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
* piece of border nearest to it lighting up.
*
* Extracted so a second panel beside the first one behaves identically. Two
* hand-copied versions of a gradient are two versions that drift, and the whole
* point of the effect is that the panels look like one material.
*
* Written straight to the DOM rather than through state: this runs on every
* mousemove, and a re-render per frame would cost more than the effect is worth.
*/
export function useGlassGlare(dark: boolean): GlassGlare {
const panelRef = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const onMouseLeave = (): void => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
}
@@ -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, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, 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) ──────────────────────────────────────
@@ -133,6 +188,21 @@ describe('readUserNote', () => {
})
})
describe('settlementDate', () => {
it('prefers settled_at over created_at', () => {
expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05')
})
it('falls back to the day it was recorded when settled_at is unset', () => {
expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
})
it('is empty when neither is set', () => {
expect(settlementDate({})).toBe('')
})
})
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
describe('calculateTicketShares', () => {
@@ -176,3 +246,67 @@ describe('calculateTicketShares', () => {
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
})
})
describe('paidByUser', () => {
it('adds up what one participant fronted and ignores the other payers', () => {
const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] }
expect(paidByUser(item, 1)).toBe(35)
expect(paidByUser(item, 2)).toBe(70)
expect(paidByUser(item, 3)).toBe(0)
})
it('is zero for an expense with no payer list', () => {
expect(paidByUser({ payers: null }, 1)).toBe(0)
expect(paidByUser({}, 1)).toBe(0)
})
})
describe('finalBudgetFor', () => {
const finals = [{
user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50,
sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] },
}]
it("returns the server's row for a participant in the ledger", () => {
expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0])
})
it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => {
expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({
user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0,
sources: { fronted: [], moved: [], outstanding: [] },
})
})
})
describe('finalBudgetSources', () => {
const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }]
const sources = {
fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }],
moved: [
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 },
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 },
],
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }],
}
it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => {
const { fronted } = finalBudgetSources({ sources }, items)
// An expense the list does not know yet keeps its row; only the name is missing.
expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }])
})
it('keeps the transfers and open flows signed as the server sent them', () => {
const { moved, outstanding } = finalBudgetSources({ sources }, items)
expect(moved).toEqual([
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 },
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 },
])
expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }])
})
it('is empty for a participant with no activity', () => {
expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items))
.toEqual({ fronted: [], moved: [], outstanding: [] })
})
})
@@ -11,15 +11,91 @@
* 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 type { BudgetParticipantFinal } from '@trek/shared'
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)
}
/**
* The calendar day a settle-up payment counts on for grouping/filtering: its own
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
* `created_at` doubled as its date before `settled_at` existed, so this keeps
* every pre-existing row grouped exactly where it already was.
*/
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
return (s.settled_at || s.created_at || '').slice(0, 10)
}
/**
* What one participant fronted for an expense, in the expense's own currency.
*
* Several payers can share one bill, and the same person can appear only once,
* but the reduce covers a row that somehow carries them twice rather than
* picking one of the two. The caller converts the result — this file never
* touches exchange rates.
*/
export function paidByUser(
item: { payers?: { user_id: number; amount: number }[] | null },
userId: number,
): number {
return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0)
}
/** The figures a final budget is made of, for someone the server left out of the ledger. */
export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal {
// Absent means they neither fronted anything nor were split into an expense:
// the trip has cost them nothing, which is worth a row of its own.
return finals.find(f => f.user_id === member.id) || {
user_id: member.id, username: member.username, avatar_url: null,
expenses: 0, reimbursed: 0, pending: 0, final: 0,
sources: { fronted: [], moved: [], outstanding: [] },
}
}
/**
* The rows behind one traveler's final budget, ready to print. They arrive as
* ids and display cents: the server spreads each of the three figures over its
* rows with the same largest-remainder split the figure came from, so every list
* adds up to the line it sits under, in whatever currency was asked for and
* whether or not the live rates have loaded yet. Only the expense names are
* looked up here; who "you" is in a transfer is the shell's call.
*/
export function finalBudgetSources(
row: Pick<BudgetParticipantFinal, 'sources'>,
items: { id: number; name: string }[],
): {
fronted: { item_id: number; name: string; amount: number }[]
moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[]
outstanding: { from_user_id: number; to_user_id: number; amount: number }[]
} {
const { fronted, moved, outstanding } = row.sources
return {
fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })),
moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
}
}
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
@@ -58,6 +134,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 +148,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)
+369 -16
View File
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
@@ -20,6 +21,9 @@ const tripMembers = [
{ id: 2, username: 'bob', avatar_url: null },
]
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded'))
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
@@ -295,6 +299,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 +545,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())
@@ -586,7 +614,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
const bobButtons = bobInForm()
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
@@ -705,8 +733,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()
})
})
@@ -715,7 +744,7 @@ describe('CostsPanel — settlements in the ledger', () => {
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; created_at?: string; settled_at?: string | null }
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
@@ -724,7 +753,7 @@ const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: Member
function mount(
items: BudgetItem[],
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {},
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {},
entries?: string[],
) {
server.use(
@@ -801,8 +830,70 @@ describe('CostsPanel — overview', () => {
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
// Both travellers appear; neither has a signed amount.
await screen.findByText('Balances')
expect(screen.getAllByText('0,00 €')).toHaveLength(2)
const balances = (await screen.findByText('Balances')).parentElement as HTMLElement
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
})
it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => {
// Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen
// on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each
// of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate
// is loaded here, so a client converting the dinner itself would print 100 €.
const user = userEvent.setup()
mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], {
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 16 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -16 },
],
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }],
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }],
finalBudgets: [
{
user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61,
sources: {
fronted: [{ item_id: 101, cents: 9200 }],
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }],
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }],
},
},
{
user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61,
sources: {
fronted: [{ item_id: 102, cents: 3000 }],
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }],
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }],
},
},
],
})
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
// The main view stays one figure per person until someone asks for more.
expect(within(card).queryByText('Expenses paid')).toBeNull()
const alice = within(card).getByRole('button', { name: /You/ })
await user.click(alice)
expect(alice).toHaveAttribute('aria-expanded', 'true')
// The dinner is the server's 92 €, once as the line and once as its only row;
// the raw 100 USD never shows up in the card.
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
expect(within(card).queryByText(/100,00/)).toBeNull()
// Received and still pending both lower her cost, each line with its one row.
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
expect(within(card).getByText('Dinner')).toBeInTheDocument()
expect(within(card).queryByText('Taxi')).toBeNull()
expect(within(card).getAllByText(/^bob → /)).toHaveLength(2)
// Opening Bob closes Alice: what he sent back and what he still owes raise his.
await user.click(within(card).getByRole('button', { name: /bob/ }))
expect(alice).toHaveAttribute('aria-expanded', 'false')
expect(within(card).getAllByText('+30,00 €')).toHaveLength(2)
expect(within(card).getAllByText('+15,00 €')).toHaveLength(2)
expect(within(card).getAllByText('+16,00 €')).toHaveLength(2)
expect(within(card).getByText('Taxi')).toBeInTheDocument()
expect(within(card).queryByText('Dinner')).toBeNull()
})
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
@@ -955,8 +1046,8 @@ describe('CostsPanel — settle up', () => {
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))
// Settle up, Balances and Final budget all hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3))
expect(screen.queryByText("Everyone's square")).toBeNull()
})
@@ -1026,6 +1117,21 @@ describe('CostsPanel — filtering the ledger', () => {
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
// must follow settled_at, the same way it already follows expense_date over
// an expense's own created_at.
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
pickOption('Sun, Jun 15')
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
expect(screen.getByText('Payment')).toBeInTheDocument()
})
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
@@ -1079,6 +1185,26 @@ describe('CostsPanel — expense rows', () => {
expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument()
})
it('FE-W5COSTS-078: a booked rate outlives the live one, for the expense and for the transfer', async () => {
// What a tester hit after settling up: the euro moved, and so did a bill that had
// already been paid. A cost is money that changed hands at a rate that was true that
// day, so the frozen rate wins over whatever the market says this morning.
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() }))
mount([expense({
id: 131, name: 'Diner', category: 'food', total_price: 120, currency: 'USD', exchange_rate: 1.2,
expense_date: '2025-06-15',
payers: [{ user_id: 1, amount: 120 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})], { settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 24, currency: 'USD', exchange_rate: 1.2, created_at: '2025-06-15 09:00:00' }] })
await screen.findByText('Diner')
// 120 USD at the booked 1.2 per euro is 100 euro. At today's 2 it would read 60,00 euro.
expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument()
expect(screen.queryByText(/60,00 €/)).toBeNull()
// The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro.
expect(screen.getByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument()
})
it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
@@ -1227,6 +1353,62 @@ describe('CostsPanel — payment modal', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
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/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 9 } })
}))
mount([])
try {
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.settled_at).toBe('2026-08-12')
} finally {
vi.useRealTimers()
}
})
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 7 } })
}))
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.settled_at).toBe('2025-06-10')
})
it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => {
// Cleared, the server would store NULL and the ledger would quietly move
// the payment back to the day it was recorded on.
const user = userEvent.setup()
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
const save = screen.getByRole('button', { name: 'Save' })
expect(save).toBeEnabled()
await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'Clear date' }))
expect(save).toBeDisabled()
})
})
describe('CostsPanel — expense modal', () => {
@@ -1264,7 +1446,7 @@ describe('CostsPanel — expense modal', () => {
const rows = screen.getAllByPlaceholderText('Item name')
await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0])
expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: /bob/i })[0])
await user.click(bobInForm()[0])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
@@ -1300,11 +1482,13 @@ 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 }))
expect(bobInForm()).toHaveLength(1)
await user.click(bobInForm()[0])
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
@@ -1430,7 +1614,8 @@ describe('CostsPanel — expense modal', () => {
)
expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument()
expect(screen.getByDisplayValue('240')).toBeInTheDocument()
// The prefilled amount is padded to the base currency's decimals (#2175).
expect(screen.getByDisplayValue('240,00')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await waitFor(() => expect(posted).toBeTruthy())
@@ -1640,7 +1825,8 @@ describe('CostsPanel — remaining paths', () => {
await screen.findByText('Mining rig')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('2')).toBeInTheDocument()
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
})
@@ -1862,6 +2048,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({})
+365 -44
View File
@@ -1,6 +1,6 @@
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 { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -8,18 +8,21 @@ import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { saveWithReceipts } from './receiptUploads'
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } 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, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
import { NumericInput } from '../shared/NumericInput'
@@ -38,7 +41,14 @@ interface Settlement {
// The currency the transfer was entered in. Legacy rows predate it (null) and are
// read as the display currency, which is what the server assumes for them too.
currency?: string | null
// The rate frozen when the transfer was settled, in units of `currency` per 1 trip
// currency (#1445). Absent, or exactly 1, on rows written before the freeze existed.
exchange_rate?: number
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field — settlementDate()
// falls back to created_at for those.
settled_at?: string | null
from_username?: string
to_username?: string
}
@@ -46,6 +56,9 @@ interface SettlementData {
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
settlements: Settlement[]
// What the trip ends up costing each participant. Computed server-side off the
// same ledger as the balances, so the breakdown can't contradict them.
finalBudgets: BudgetParticipantFinal[]
}
// One row in the unified Costs ledger — either an expense or a settle-up payment,
@@ -84,9 +97,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
// One note open at a time: two expanded rows next to each other read as a mess,
// and the point of the collapse is that the list stays scannable.
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
// One open final-budget breakdown at a time, for the same reason a single note
// is expanded at a time: the card is a sidebar, not a report.
const [expandedFinalId, setExpandedFinalId] = useState<number | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
@@ -121,21 +138,36 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}, [searchParams])
// ── 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)
// Booked, not live: an expense entered in a foreign currency keeps the rate it was
// entered at, which is the same rule the server settles by (#1335).
const booked = useCallback(
(amount: number, e: BudgetItem) => convertBooked(amount, e.currency, e.exchange_rate, tripCurrency, convert),
[convert, tripCurrency],
)
const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e)
// A transfer freezes its own rate at settle time, in its own table (#1445).
const settled = useCallback(
(s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert),
[convert, tripCurrency],
)
const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), 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. 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) {
return convert(myMember.amount, curOf(e))
return booked(myMember.amount, e)
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
return booked(myShare, 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)
@@ -171,14 +203,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter)
return list
}, [settlement, filter, search, catFilter, dayFilter, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })),
]
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
@@ -467,6 +499,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* final budget */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
{FinalBudgetList()}
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
@@ -488,6 +526,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
{previewReceipts && (
<ReceiptPreviewModal
receipts={previewReceipts.receipts}
initialIndex={previewReceipts.initialIndex}
onClose={() => setPreviewReceipts(null)}
/>
)}
<style>{`
.costs-root {
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
@@ -679,6 +725,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* Final budget */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
{FinalBudgetList()}
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
@@ -717,7 +769,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const c = catMeta(e.category)
const Icon = c.Icon
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const payers = (e.payers || []).filter(p => p.amount !== 0)
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
const note = readUserNote(e)
@@ -758,6 +810,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.unfinished')}
</span>
)}
{(e.receipts || []).length > 0 && (
<button
type="button"
onClick={(ev) => {
ev.stopPropagation()
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
}}
title={t('costs.viewReceipt')}
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 8px',
borderRadius: 999,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 600,
flexShrink: 0,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<Receipt size={12} className="text-content-muted" />
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
</button>
)}
</div>
{cur !== base && (
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -771,7 +849,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{payers.map(p => (
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(booked(p.amount, e))}</span>
</span>
))}
</div>
@@ -823,7 +901,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
{t('costs.payment')}
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(convert(s.amount, cur))}</span>}
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(settled(s))}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
@@ -834,7 +912,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
on the same axis as every expense above it. */}
{!isMobile && <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(convert(s.amount, cur))}</span>
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(settled(s))}</span>
</div>
</div>
{canEdit && (
@@ -877,10 +955,119 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
/**
* What the trip actually costs each traveler — one amount per person, with the
* arithmetic behind it a click away.
*
* The four figures come from the settlement response, not from a second pass
* over the expenses here: the server nets them in the same integer cents as
* the balances, so `expenses − reimbursed − pending` always lands on the total
* printed beside the name. The lists under the breakdown are the rows those
* figures came from, which is what makes an unexpected total explainable.
*/
function FinalBudgetList() {
if (settlementError) return loadFailed()
const finals = settlement?.finalBudgets || []
const rows = people.map(p => finalBudgetFor(finals, p))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{rows.map(r => {
const open = expandedFinalId === r.user_id
return (
<div key={r.user_id}>
<button type="button" onClick={() => setExpandedFinalId(open ? null : r.user_id)} aria-expanded={open}
className="text-content"
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' }}>
<Avatar id={r.user_id} size={28} />
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{personName(r.user_id)}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(r.final)}</span>
<ChevronDown size={14} className="text-content-faint" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{open && FinalBudgetBreakdown({ row: r })}
</div>
)
})}
</div>
)
}
/** The three lines behind one traveler's final budget, and the rows they add up from. */
function FinalBudgetBreakdown({ row }: { row: BudgetParticipantFinal }) {
// Each line is signed by what it does to the final, so the column reads as the
// subtraction it is: a reimbursement this traveler *sent* raises their cost and
// shows as a plus, which "received: −50" could never say. The rows under a line
// carry the same sign, so they visibly add up to it. Their amounts are the
// server's display cents, not a conversion of the expense list done here.
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
const detail = (label: string, value: string) => (
<div style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1 }}>{label}</span>
<span className="text-content" style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{value}</span>
</div>
)
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
// Capped and scrollable: a long trip's expense list would otherwise push the
// rest of the sidebar off the screen every time a name is tapped.
const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const
return (
<div className="bg-surface-secondary" style={{ borderRadius: 12, padding: '11px 12px', margin: '2px 0 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{detail(t('costs.finalExpenses'), signed(row.expenses))}
{detail(t('costs.finalReimbursed'), signed(-row.reimbursed))}
{detail(t('costs.finalPending'), signed(-row.pending))}
</div>
{fronted.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalExpenses')}</div>
<div style={listCls}>
{fronted.map(r => (
<div key={r.item_id} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
</div>
))}
</div>
</div>
)}
{moved.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalReimbursed')}</div>
<div style={listCls}>
{moved.map(r => (
<div key={r.settlement_id} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
</div>
))}
</div>
</div>
)}
{outstanding.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalPending')}</div>
<div style={listCls}>
{outstanding.map((r, i) => (
<div key={i} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
function CategoryBreakdown() {
// Categories net refunds against spend (#2176): a negative entry lowers its
// category's sum, and a category that nets negative keeps its own row —
// just without a bar, since the bars rank positive spend.
const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
@@ -888,7 +1075,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -966,18 +1153,21 @@ 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 [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
const [saving, setSaving] = useState(false)
const amt = Number.parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const valid = amt > 0 && fromId !== toId && !!day
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1021,6 +1211,10 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
style={{ width: '100%' }} />
</div>
</div>
<div>
<label className={labelCls}>{t('costs.day')}</label>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
</Modal>
)
@@ -1051,9 +1245,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || localToday())
const [note, setNote] = useState(() => readUserNote(editing))
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
// which is also what the currency field starts on.
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
if (prefill?.amount != null) return String(prefill.amount)
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
return ''
})
const [participants, setParticipants] = useState<Set<number>>(() =>
@@ -1064,8 +1262,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
// existing expense a missing payer is a deliberate choice, so only a brand-new
// one defaults to me.
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
// real payer — filtering on > 0 here would silently drop them on save.
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = initialPayers[0]
@@ -1076,7 +1275,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of initialPayers) m[p.user_id] = String(p.amount)
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
return m
})
// Payers the user typed an amount for: rebalance leaves these alone and makes
@@ -1101,13 +1300,31 @@ 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)
}
}
}
return m
})
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
if (!files || files.length === 0) return
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
}
const handleRemoveReceipt = (receiptId: number) => {
setReceipts(prev => prev.filter(r => r.id !== receiptId))
}
const handleRemovePendingReceipt = (index: number) => {
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
}
const [saving, setSaving] = useState(false)
const isTicketMode = splitMode === 'ticket'
@@ -1119,6 +1336,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
// How much is still to be handed out, read on the total's own side: on a refund
// (#2176) the shares run negative, so a plain total minus sum flips under and
// over around and sends the user the wrong way.
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
@@ -1131,17 +1352,22 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
// Clamped toward zero on the total's own side, so an over-entered positive
// split never suggests negative leftovers — while a negative total (#2176)
// still previews its negative equal shares.
const rest = totalNum - enteredSum
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
const valid = name.trim().length > 0 && payersOk && (
isTicketMode
? ticketValid
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
)
const onTotalChange = (v: string) => {
@@ -1196,7 +1422,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const handleCustomAmountChange = (id: number, val: string) => {
val = val.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
if (val === '' || amountPattern(currency, true).test(val)) {
setCustomAmounts(prev => ({ ...prev, [id]: val }))
}
}
@@ -1219,7 +1445,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
if (price === '' || amountPattern(currency, false).test(price)) {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
@@ -1264,7 +1490,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const payerList = multiPayer
? [...payerIds]
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
.filter(p => p.amount !== 0)
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
// A receipt line can name somebody who is not ticked as a participant. Sending
// only the ticked set would drop their share, leaving the member sum short of
@@ -1295,12 +1521,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
setUploadingReceipt(pendingReceiptFiles.length > 0)
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
editing
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
))
// Only cleared once the save went through, so a retry after a failure
// does not upload a second copy of every file.
setPendingReceiptFiles([])
onSaved()
} catch {
toast.error(t('common.unknownError'))
} catch (err) {
// A receipt the rollback could not remove is still on the trip, and the
// user is the only one who can clear it out of the Files tab.
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
} finally {
setUploadingReceipt(false)
setSaving(false)
}
}
@@ -1334,8 +1571,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
signToggleLabel={t('costs.toggleSign')}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
@@ -1353,7 +1591,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
{currency !== base && totalNum > 0 && (
{currency !== base && totalNum !== 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint">≈</span>
@@ -1422,7 +1660,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="text-content"
@@ -1591,7 +1829,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? t('costs.splitBalanced')
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
: t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
sum: sym(currency) + splitSum.toFixed(2),
total: sym(currency) + totalNum.toFixed(2),
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
@@ -1609,8 +1847,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
</div>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
<input
type="file"
multiple
accept="image/*,application/pdf"
style={{ display: 'none' }}
onChange={e => {
handleReceiptFileSelect(e.target.files)
e.target.value = ''
}}
/>
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
<Plus size={13} /> {t('costs.attachReceipt')}
</span>
</label>
</div>
{uploadingReceipt && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
{t('common.saving')}...
</div>
)}
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
{t('costs.noReceipts')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{receipts.map((r, rIdx) => (
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<button
type="button"
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
className="text-content hover:underline"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<Receipt size={14} className="text-content-faint flex-shrink-0" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
</button>
<button
type="button"
onClick={() => handleRemoveReceipt(r.id)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
{pendingReceiptFiles.map((file, idx) => (
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
</div>
<button
type="button"
onClick={() => handleRemovePendingReceipt(idx)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{modalPreviewReceipts && (
<ReceiptPreviewModal
receipts={modalPreviewReceipts.receipts}
initialIndex={modalPreviewReceipts.initialIndex}
onClose={() => setModalPreviewReceipts(null)}
/>
)}
</Modal>
)
}
@@ -0,0 +1,99 @@
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
// on top of the expense form.
import { vi } from 'vitest'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItemReceipt } from '../../types'
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
return {
id: 1,
filename: 'stored.jpg',
original_name: 'lunch.jpg',
mime_type: 'image/jpeg',
file_size: 100,
url: '/uploads/files/stored.jpg',
...over,
} as BudgetItemReceipt
}
beforeEach(() => {
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
})
describe('ReceiptPreviewModal', () => {
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
})
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
// The viewer renders through a portal, so it lives on document.body.
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
expect(screen.queryByRole('img')).toBeNull()
})
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
authUrl.get.mockRejectedValue(new Error('403'))
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
})
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
const onClose = vi.fn()
const parent = vi.fn()
document.addEventListener('keydown', parent)
try {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// The expense form registers its own Escape handler on `document`; one
// keypress must not close both and throw away the user's edits.
expect(parent).not.toHaveBeenCalled()
} finally {
document.removeEventListener('keydown', parent)
}
})
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
})
@@ -0,0 +1,405 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
import type { BudgetItemReceipt } from '../../types'
interface ReceiptPreviewModalProps {
receipts: BudgetItemReceipt[]
initialIndex?: number
onClose: () => void
}
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
const [signedUrl, setSignedUrl] = useState('')
const [loading, setLoading] = useState(false)
const current = receipts[index]
const isImg = isImage(current?.mime_type)
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
useEffect(() => {
if (!current) return
let cancelled = false
setLoading(true)
setSignedUrl('')
getAuthUrl(current.url, 'download')
.then(url => {
if (!cancelled) {
setSignedUrl(url)
setLoading(false)
}
})
.catch(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [current?.url])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// The preview opens on top of the expense form, and both the desktop
// Modal and the phone MSheet close themselves on Escape from a listener
// on `document`. Without this the one keypress closed the form too and
// threw away whatever the user had typed into it.
e.preventDefault()
e.stopPropagation()
onClose()
return
}
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
}
// Capture phase, so this runs before the listeners the parents put on
// `document`; stopPropagation on the bubble phase would be too late.
window.addEventListener('keydown', handleKey, true)
return () => window.removeEventListener('keydown', handleKey, true)
}, [index, receipts.length, onClose])
if (!current) return null
const handleOpenTab = () => {
openFileUrl(current.url, current.original_name).catch(() => {})
}
const handleDownload = () => {
triggerDownload(current.url, current.original_name)
}
return createPortal(
<div
role="presentation"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
role="presentation"
style={{
width: '100%',
maxWidth: 950,
height: '94vh',
background: 'var(--bg-card)',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header - identical to PdfPreviewModal */}
<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,
}}
>
{current.original_name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={handleOpenTab}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
type="button"
onClick={handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
padding: 4,
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<X size={18} />
</button>
</div>
</div>
{/* Tab strip if multiple receipts */}
{receipts.length > 1 && (
<div
className="bg-surface-secondary"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderBottom: '1px solid var(--border-primary)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{receipts.map((r, i) => {
const active = i === index
const isRImg = isImage(r.mime_type)
const Icon = isRImg ? FileImage : FileText
return (
<button
key={r.id}
type="button"
onClick={() => setIndex(i)}
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 11px',
borderRadius: 8,
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
background: active ? 'var(--bg-card)' : 'none',
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
>
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.original_name}
</span>
<span
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
opacity: active ? 0.8 : 0.5,
marginLeft: 2,
}}
>
#{i + 1}
</span>
</button>
)
})}
</div>
)}
{/* Content Body */}
<div
style={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: isImg ? '#0f141c' : 'var(--bg-card)',
minHeight: 0,
}}
>
{receipts.length > 1 && index > 0 && (
<button
type="button"
onClick={() => setIndex(i => i - 1)}
style={{
position: 'absolute',
left: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronLeft size={20} />
</button>
)}
{receipts.length > 1 && index < receipts.length - 1 && (
<button
type="button"
onClick={() => setIndex(i => i + 1)}
style={{
position: 'absolute',
right: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronRight size={20} />
</button>
)}
{isPdf ? (
<object
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
title={current.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button
type="button"
onClick={handleOpenTab}
style={{
color: 'var(--text-primary)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
font: 'inherit',
}}
>
{t('files.downloadPdf')}
</button>
</p>
</object>
) : isImg ? (
<div
style={{
padding: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
>
{signedUrl ? (
<img
src={signedUrl}
alt={current.original_name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 6,
display: 'block',
}}
/>
) : (
<div className="text-content-faint" style={{ padding: 48 }}>
{loading ? `${t('common.loading')}...` : current.original_name}
</div>
)}
</div>
) : (
<div style={{ padding: 40, textAlign: 'center' }}>
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
{current.original_name}
</p>
<button
type="button"
onClick={handleDownload}
className="bg-accent text-accent-text"
style={{
padding: '9px 18px',
borderRadius: 10,
border: 0,
cursor: 'pointer',
fontWeight: 600,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
}}
>
{t('files.download')}
</button>
</div>
)}
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,91 @@
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
// expense, and taking the uploads back out when the expense fails to save.
import { vi } from 'vitest'
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
const files = vi.hoisted(() => ({
upload: vi.fn(),
delete: vi.fn(),
permanentDelete: vi.fn(),
}))
vi.mock('../../api/client', () => ({ filesApi: files }))
function file(name: string) {
return new File(['x'], name, { type: 'image/jpeg' })
}
beforeEach(() => {
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
files.delete.mockReset().mockResolvedValue({})
files.permanentDelete.mockReset().mockResolvedValue({})
})
describe('uploadReceipts', () => {
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
await uploadReceipts(3, [file('a.jpg')], 42)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBe('42')
})
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
await uploadReceipts(3, [file('a.jpg')], null)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBeNull()
})
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
files.upload
.mockResolvedValueOnce({ file: { id: 11 } })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ file: { id: 13 } })
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
})
})
describe('discardReceipts', () => {
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
const order: string[] = []
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
expect(await discardReceipts(3, [9])).toEqual([])
expect(order).toEqual(['delete', 'permanent'])
})
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
files.delete.mockRejectedValueOnce(new Error('403'))
expect(await discardReceipts(3, [9, 10])).toEqual([9])
})
})
describe('saveWithReceipts', () => {
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockResolvedValue('saved')
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
expect(save).toHaveBeenCalledWith([5, 6])
expect(out.result).toBe('saved')
expect(files.delete).not.toHaveBeenCalled()
})
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockRejectedValue(new Error('nope'))
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
})
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
files.delete.mockRejectedValueOnce(new Error('403'))
const save = vi.fn()
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
expect(save).not.toHaveBeenCalled()
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
expect(err.stuckReceiptIds).toEqual([5])
})
})
@@ -0,0 +1,87 @@
import { filesApi } from '../../api/client'
/**
* Uploading receipts alongside an expense, and undoing it when the expense
* itself fails to save.
*
* One module for both shells. The desktop form and the phone sheet each had
* their own copy of this, three near-identical try/catch blocks apiece, which is
* six places for the same bug to sit and six copies against the duplication
* budget the PR gate measures.
*/
/** Upload each file and return the ids the server handed back, in order. */
export async function uploadReceipts(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
): Promise<number[]> {
const ids: number[] = []
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
// Only an existing expense can be named here. A new one has no id yet, so
// its receipts are linked by the create call instead.
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) ids.push(res.file.id)
}
return ids
}
/**
* Undo an upload after the save it belonged to failed.
*
* Two calls per file, and the order matters: the permanent route only accepts a
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
* delete puts it there first. It also drops the receipt link the upload created,
* so a failed edit does not leave the file attached to the expense.
*
* Returns the ids it could not remove. The caller is expected to say so rather
* than swallow it: a file left behind is a file the user has to find in the
* Files tab and delete by hand.
*/
export async function discardReceipts(
tripId: number | string,
ids: readonly number[],
): Promise<number[]> {
const stuck: number[] = []
for (const id of ids) {
try {
await filesApi.delete(tripId, id)
await filesApi.permanentDelete(tripId, id)
} catch {
stuck.push(id)
}
}
return stuck
}
/**
* Upload the pending receipts, run the save, and take the uploads back out
* again if it throws.
*
* The rollback runs for a failure in the save AND for one partway through the
* upload loop, which is why the ids are collected outside the try.
*/
export async function saveWithReceipts<T>(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
save: (uploadedIds: number[]) => Promise<T>,
): Promise<{ result: T; stuckIds: number[] }> {
const uploaded: number[] = []
try {
for (const file of files) {
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
if (id != null) uploaded.push(id)
}
const result = await save(uploaded)
return { result, stuckIds: [] }
} catch (err) {
const stuck = await discardReceipts(tripId, uploaded)
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
throw err
}
}
@@ -708,4 +708,31 @@ describe('CollabChat', () => {
{ timeout: 3000 }
);
});
// The strip shows the four pictures that will go out; the two the cap turned away
// vanish without a word otherwise, and the sender believes all six were sent.
it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => {
const addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
let minted = 0;
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`);
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
try {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' }));
fireEvent.change(input, { target: { files } });
await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4));
expect(addToast).toHaveBeenCalledTimes(1);
const [message, type] = addToast.mock.calls[0];
expect(type).toBe('error');
expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed');
} finally {
delete window.__addToast;
createObjURL.mockRestore(); revokeObjURL.mockRestore();
}
});
});
+11 -6
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
import type { User } from '../../types'
import { useCollabChat } from './useCollabChat'
import { ChatMessages } from './CollabChatMessages'
@@ -14,7 +14,7 @@ interface CollabChatProps {
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const S = useCollabChat(tripId, currentUser)
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */}
{replyTo && (
<div style={{
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label={t('places.removeImage')} style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('collab.chat.uploading', { percent: uploadProgress })}</div>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</button>
)}
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label={t('collab.chat.attachImages')} style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
<textarea
ref={textareaRef}
rows={1}
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
/>
{/* Send */}
{canEdit && (
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
@@ -0,0 +1,13 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { useTranslation } from '../../i18n'
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const alt = attachment.original_name || t('collab.chat.attachedImage')
return <>
<img src={attachment.url} alt={alt} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label={t('collab.chat.closeImage')} onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={alt} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
</>
}
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
import { URL_REGEX } from './CollabChat.constants'
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
import { MessageText } from './CollabChatMessageText'
import { CollabChatAttachment } from './CollabChatAttachment'
import { LinkPreview } from './CollabChatLinkPreview'
import { ReactionBadge } from './CollabChatReactionBadge'
import EmptyState from '../shared/EmptyState'
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
@@ -0,0 +1,129 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabLinks from './CollabLinks';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const buildLink = (overrides: Record<string, unknown> = {}) => ({
id: 1,
title: 'Ferry timetable',
url: 'https://ferries.example/timetable',
pinned: 0,
...overrides,
});
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
});
describe('CollabLinks', () => {
it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => {
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
// The mascot carries the scene class; it is what makes this panel match its siblings.
await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument());
});
it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => {
render(<CollabLinks tripId={1} />);
const heading = await screen.findByRole('heading', { level: 3 });
expect(heading).toHaveTextContent(/links/i);
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-003: renders a link with its title and url', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
});
it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => {
const user = userEvent.setup();
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
});
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
const user = userEvent.setup();
let posted: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/links', async ({ request }) => {
posted = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) });
}),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
});
it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
});
it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) });
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,232 @@
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
const FONT = "var(--font-system)"
interface CollabLink {
id: number
title: string
url: string
pinned: boolean | number
}
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
}
/**
* The add form as a modal, like the poll and note panels next to it. Inline it
* pushed the list down and gave the panel a second header, which is what made
* this tab read as a different app from its four siblings.
*/
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const valid = title.trim().length > 0 && url.trim().length > 0
const submit = async (event: FormEvent) => {
event.preventDefault()
if (!valid || busy) return
setBusy(true)
try {
await onCreate({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// add() already said what went wrong; the form stays open with the input in it.
} finally {
setBusy(false)
}
}
const field: CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
}
const label: CSSProperties = {
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
}
return createPortal(
<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={submit}>
<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.links.add')}</h3>
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
</div>
<div>
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
</div>
<button type="submit" disabled={!valid || busy} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
}}>
{busy ? '...' : t('collab.links.save')}
</button>
</div>
</form>
</div>,
document.body,
)
}
export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
// Both are new objects on every render, so depending on them would reload the
// list after each keystroke and drop a link that was just added back out of it.
const toastRef = useRef(toast)
toastRef.current = toast
const tRef = useRef(t)
tRef.current = t
useEffect(() => {
let cancelled = false
collabApi.getLinks(tripId)
.then(d => { if (!cancelled) setLinks(d.links || []) })
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toastRef.current.error(tRef.current('common.error'))
})
return () => { cancelled = true }
}, [tripId])
// Live sync, the same shape the notes panel uses. Without it the three
// collab:link events the server broadcasts would arrive nowhere and a link
// somebody else added would only appear on the next reload.
useEffect(() => {
if (!tripId) return
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
// 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:link:created' && msg.link) {
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
}
if (msg.type === 'collab:link:updated' && msg.link) {
setLinks(prev => prev
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
}
if (msg.type === 'collab:link:deleted' && msg.linkId) {
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId])
// Every write says something when it fails. Silence left the pin looking
// unchanged and the delete looking ignored, with an unhandled rejection in
// the console as the only trace.
const add = async (data: { title: string; url: string }) => {
try {
const d = await collabApi.createLink(tripId, data)
setLinks(v => [d.link, ...v])
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
throw err
}
}
const toggle = async (link: CollabLink) => {
try {
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
} catch (err) {
console.error('Failed to pin collab link:', err)
toast.error(t('common.error'))
}
}
const remove = async (id: number) => {
try {
await collabApi.deleteLink(tripId, id)
setLinks(v => v.filter(x => x.id !== id))
} catch (err) {
console.error('Failed to delete collab link:', err)
toast.error(t('common.error'))
}
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<Link2 size={14} color="var(--text-faint)" />
{t('collab.tabs.links')}
</h3>
{canEdit && (
<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',
}}>
<Plus size={12} /> {t('collab.links.add')}
</button>
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
</a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
{canEdit && <>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
</div>
)
}
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
});
});
// FE-W5CNT-001 to FE-W5CNT-029
// FE-W5CNT-001 to FE-W5CNT-036
// Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach.
@@ -1327,6 +1327,14 @@ function wsHandler(): (msg: Record<string, unknown>) => void {
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
}
function openedNoteBody(): Promise<HTMLElement> {
return waitFor(() => {
const md = document.querySelector('.collab-note-md-full');
if (!md) throw new Error('view modal not open yet');
return md as HTMLElement;
});
}
describe('CollabNotes details', () => {
let addToast: ReturnType<typeof vi.fn<AddToast>>;
let filesChanged: number;
@@ -1912,4 +1920,59 @@ describe('CollabNotes details', () => {
// …and both rejected writes are one message, not one each.
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-W5CNT-033: a placeholder in angle brackets survives sanitizing', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 7, title: 'Hotel', content: 'Zimmernummer <TBD> bestaetigen' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
// Notes written before markdown rendering existed keep reading the way they
// were typed (#2177).
expect(within(full).getByText('Zimmernummer <TBD> bestaetigen')).toBeInTheDocument();
});
it('FE-W5CNT-034: a footnote reference lands on the footnote instead of a new tab', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 8, title: 'Booking', content: 'Hotel gebucht[^1]\n\n[^1]: Bestaetigung ABC123' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Booking');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
const ref = within(full).getByRole('link', { name: '1' });
const href = ref.getAttribute('href')!;
expect(href.startsWith('#')).toBe(true);
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
expect(ref).not.toHaveAttribute('target');
});
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
// A member without collab_edit never opens the edit modal, so this is the
// only place the link is readable for them (#2222).
const link = within(full).getByRole('link');
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveTextContent('hotel.test');
});
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
expect(within(full).queryAllByRole('link')).toHaveLength(0);
});
});
+24 -2
View File
@@ -2,8 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
import { createPortal } from 'react-dom'
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -20,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
import { NoteCard } from './CollabNotesCard'
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
import { AuthedImg } from './CollabNotesAuthedImg'
import { safeExternalHref } from '../../utils/safeUrl'
// ── Main Component ──────────────────────────────────────────────────────────
interface CollabNotesProps {
@@ -392,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
)
}
/** Label for the link chip: the host is what people recognise, "www." is noise. */
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
// A member without collab_edit only ever gets this modal, so the link has to
// be here too (#2222). Allow-listed like the card tile: the field takes any
// string, and a javascript: one would run in this origin.
const websiteHref = safeExternalHref(viewingNote.website)
return createPortal(
<div
style={{
@@ -445,7 +456,18 @@ function ViewNoteModal(S: NotesState) {
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
{websiteHref && (
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-primary)', textDecoration: 'none',
}}>
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
</a>
)}
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -3,6 +3,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
import { FONT } from './CollabNotes.constants'
import { AuthedImg } from './CollabNotesAuthedImg'
@@ -142,7 +143,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
</div>
)}
</div>
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
vi.mock('../../api/websocket', () => ({
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
})
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
it('desktop layout renders all five panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
// Only chat visible by default
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it('mobile: Links tab shows the translated label, not the i18n key', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375)
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
// FE-W5CPN-001 to FE-W5CPN-013
// The desktop branch picks a different layout for every combination of enabled
// collab features, so each combination gets its own case.
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
describe('CollabPanel feature combinations', () => {
beforeEach(() => {
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
// The three-or-more branch used to render notes and links as a pair and drop
// whichever of the two was on its own, so these walk every shape of it.
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
})
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
expect(screen.getByTestId(id)).toBeInTheDocument()
}
})
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
})
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
setViewport(375)
const { rerender } = render(<CollabPanel tripId={1} />)
+45 -54
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
import WhatsNextWidget from './WhatsNextWidget'
import CollabLinks from './CollabLinks'
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
@@ -28,6 +29,7 @@ interface TripMember {
interface CollabFeatures {
chat: boolean
notes: boolean
links?: boolean
polls: boolean
whatsnext: boolean
}
@@ -39,10 +41,11 @@ interface CollabPanelProps {
}
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const { t } = useTranslation()
const isDesktop = useIsDesktop()
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
// Older server/admin configs predate the Links feature; merge defaults so a
// missing `links` key does not silently hide the new panel.
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey),
label: t(tab.labelKey) || tab.fallback,
})),
[features, t])
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes',
features.notes && 'notes', features.links && 'links',
features.polls && 'polls',
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
// One place decides what a panel id renders. It was spelled out at five call
// sites, and the layout branch below dropped a panel precisely because one of
// those copies did not list it.
const renderPanel = (p: string) => (
<>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</>
)
const panelRow = (ids: string[]) => (
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
</div>
)
if (tabs.length === 0) return null
if (isDesktop) {
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div className={cardClass} style={{ flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && rightPanels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div className={cardClass} style={{ flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div className={cardClass} style={{ flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
{rightPanels.length <= 2 && panelRow(rightPanels)}
{rightPanels.length >= 3 && (() => {
// Two rows, split by kind rather than by a pair being present:
// the old condition needed notes AND links together, so turning
// the new Links feature off dropped Notes out of the layout
// entirely. Each row renders only when it has something in it.
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
return <>
{written.length > 0 && panelRow(written)}
{rest.length > 0 && panelRow(rest)}
</>
)}
})()}
</div>
</div>
)
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
// Chat off — remaining panels share full width
const panels = rightPanels
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
))}
</div>
)
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<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',
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
@@ -732,3 +732,146 @@ describe('CollabPolls details', () => {
}
});
});
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
// Markdown questions, sanitization and multiline input (#2177).
describe('CollabPolls markdown & multiline', () => {
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const heading = await screen.findByText('Day trip');
expect(heading.closest('h2')).not.toBeNull();
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
});
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
}),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/Safe title/);
expect(document.querySelector('script')).toBeNull();
expect(document.querySelector('img[src="x"]')).toBeNull();
expect(document.querySelector('[onerror]')).toBeNull();
});
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const first = await screen.findByText('First paragraph');
const second = screen.getByText('Second paragraph');
expect(first.tagName).toBe('P');
expect(second.tagName).toBe('P');
expect(first).not.toBe(second);
});
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const question = await screen.findByText('Best destination?');
expect(question.textContent).toBe('Best destination?');
// The question sits in a paragraph now, but no formatting was invented
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
});
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/polls', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
const questionField = screen.getByPlaceholderText(/what should we do/i);
expect(questionField.tagName).toBe('TEXTAREA');
await user.type(questionField, 'Line one{enter}Line two');
// Enter stayed in the field — the form was not submitted without options
expect(body).toBeNull();
expect(questionField).toHaveValue('Line one\nLine two');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs[0].tagName).toBe('TEXTAREA');
await user.type(optionInputs[0], 'A');
await user.type(optionInputs[1], 'B');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
});
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const link = await screen.findByRole('link', { name: 'Our hotel' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({
polls: [buildPoll({
options: [
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
{ id: 2, text: 'Rome', voters: [] },
],
})],
}),
),
);
render(<CollabPolls {...defaultProps} />);
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
expect(label.style.whiteSpace).toBe('pre-wrap');
expect(label.style.overflowWrap).toBe('anywhere');
expect(label.style.minWidth).toBe('0px');
});
it('FE-COMP-POLLS-023: a placeholder in angle brackets survives sanitizing', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Treffpunkt <noch offen> oder Hotel?' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
// Questions written before markdown rendering existed must keep reading the
// way they were typed, and they cannot be edited afterwards (#2177).
expect(await screen.findByText('Treffpunkt <noch offen> oder Hotel?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-024: a script tag is shown as text, never as an element', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Plan <script>alert("xss")</script>' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('Plan <script>alert("xss")</script>')).toBeInTheDocument();
expect(document.querySelector('script')).toBeNull();
});
});
+45 -7
View File
@@ -1,5 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
@@ -35,6 +39,34 @@ interface Poll {
const FONT = "var(--font-system)"
// Block styling for the markdown question (#2177). Tailwind's preflight resets
// headings/lists, so the sizes are set here — in em, on purpose: they scale with
// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's
// text-size setting keeps working. Links and sanitizing come from
// markdownSanitize (#1629): new tab plus rel protection, this is cross-user
// content.
const pollMarkdownComponents: Components = {
...sanitizedMarkdownComponents,
p: ({ children }) => (
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
),
h1: ({ children }) => (
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
),
}
function timeRemaining(deadline) {
if (!deadline) return null
const diff = new Date(deadline).getTime() - Date.now()
@@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Question */}
<div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
</div>
{/* Options */}
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)}
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question}
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={pollMarkdownComponents}>
{poll.question}
</ReactMarkdown>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
</div>
{/* Label */}
{/* Label — options stay plain text, but long or multiline ones
must wrap instead of being clipped by the button's
overflow:hidden (#2177) */}
<span style={{
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.text}
@@ -0,0 +1,135 @@
/**
* FE-CHATIMG-001..009: the pictures pinned to a chat message before it is sent.
*
* The reason this file exists: the hook used to create object URLs and enqueue
* the preview state from inside a `setFiles` updater. React invokes an updater
* an extra time in development, so one picked image produced two preview URLs
* against one file, the composer strip drew two thumbnails for one attachment,
* and removing one of them left a preview for a picture that would not be sent.
* `renderHook` under StrictMode is what reproduces it.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StrictMode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { useChatImages, MAX_CHAT_IMAGES, type ChatImageDrop } from './useChatImages'
let nextUrl = 0
const revoked: string[] = []
beforeEach(() => {
nextUrl = 0
revoked.length = 0
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`),
revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }),
})
})
const img = (name: string, size = 1024) =>
new File([new Uint8Array(size)], name, { type: 'image/png' })
const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode })
describe('useChatImages', () => {
it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
expect(result.current.files).toHaveLength(1)
expect(result.current.previews).toHaveLength(1)
expect(result.current.previews[0]).toBe('blob:mock/1')
})
it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.remove(0) })
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
expect(revoked).toEqual(['blob:mock/1'])
})
it('FE-CHATIMG-003: files and previews stay in step across several adds', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.add([img('b.png'), img('c.png')]) })
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png'])
expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3'])
})
it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => {
const { result } = strict()
let ok = true
act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) })
expect(ok).toBe(false)
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
})
it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => {
const { result } = strict()
act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) })
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES)
})
it('FE-CHATIMG-006: clear revokes every URL it handed out', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png'), img('b.png')]) })
act(() => { result.current.clear() })
expect(result.current.files).toEqual([])
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
})
// The strip shows what will be sent, so a picture the cap turned away is as gone as
// one of the wrong kind. The sender hears about both, and hears which it was.
it('FE-CHATIMG-007: a pick past the cap is reported as an overflow and is not a clean add', () => {
const { result } = strict()
const drops: ChatImageDrop[] = []
let ok = true
act(() => {
ok = result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`)), d => drops.push(d))
})
expect(ok).toBe(false)
expect(drops).toEqual([{ rejected: 0, overflow: 2 }])
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
})
it('FE-CHATIMG-008: a wrong kind and an overflow in one pick are told apart', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png'), img('b.png'), img('c.png')]) })
const drops: ChatImageDrop[] = []
act(() => {
result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' }), img('d.png'), img('e.png')], d => drops.push(d))
})
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png', 'd.png'])
expect(drops).toEqual([{ rejected: 1, overflow: 1 }])
})
it('FE-CHATIMG-009: a pick that fits is a clean add and reports nothing', () => {
const { result } = strict()
const onDrop = vi.fn()
let ok = false
act(() => { ok = result.current.add([img('a.png')], onDrop) })
expect(ok).toBe(true)
expect(onDrop).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
*
* One hook for both shells. The desktop hook and the phone screen each carried
* their own copy of the same picking, previewing and clearing, which is two
* places for the same leak and a duplication bill on every line.
*
* A file and the object URL that previews it are one thing, so they are stored
* as one thing and the two arrays the callers read are derived from it. Every
* mutator computes the next list first and then commits it: nothing is created
* or revoked inside a state updater, because React invokes those an extra time
* in development and the second run would mint a second URL for the same file.
* That is what the strip showed as two thumbnails for one attachment, and
* removing one of them left a preview for a picture that was never sent.
*
* The ref is the list, the state is the render of it. Written together so two
* calls in the same tick see each other, which a functional updater would give
* for free and a plain array would not.
*/
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
interface PickedImage {
file: File
url: string
}
/**
* What an add() left out: files of the wrong type or over the size limit, and
* valid files past the cap of MAX_CHAT_IMAGES. Either can be zero, both cannot.
*/
export interface ChatImageDrop {
rejected: number
overflow: number
}
export interface ChatImages {
files: File[]
previews: string[]
/**
* Returns false when something was dropped, so the caller can say why. The
* optional callback tells it what was dropped, because a picture past the cap
* is a different message from a picture of the wrong kind.
*/
add: (incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => boolean
remove: (index: number) => void
clear: () => void
}
export function useChatImages(): ChatImages {
const [items, setItems] = useState<PickedImage[]>([])
const itemsRef = useRef<PickedImage[]>([])
const commit = useCallback((next: PickedImage[]) => {
itemsRef.current = next
setItems(next)
}, [])
// Unmount with pictures still pinned: the URLs outlive the component otherwise.
useEffect(() => () => {
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
itemsRef.current = []
}, [])
const add = useCallback((incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
const room = MAX_CHAT_IMAGES - itemsRef.current.length
const added = valid.slice(0, Math.max(0, room))
if (added.length) {
// Only the new files get a URL; the ones already in the list keep theirs.
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
}
// A picture the cap turned away is as gone as one of the wrong kind: the strip
// shows what will be sent, and the sender deserves to hear why it is short.
const drop = { rejected: all.length - valid.length, overflow: valid.length - added.length }
if (drop.rejected || drop.overflow) onDrop?.(drop)
return drop.rejected === 0 && drop.overflow === 0
}, [commit])
const remove = useCallback((index: number) => {
const gone = itemsRef.current[index]
if (!gone) return
URL.revokeObjectURL(gone.url)
commit(itemsRef.current.filter((_, i) => i !== index))
}, [commit])
const clear = useCallback(() => {
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
commit([])
}, [commit])
const files = useMemo(() => items.map(item => item.file), [items])
const previews = useMemo(() => items.map(item => item.url), [items])
return { files, previews, add, remove, clear }
}
+28 -9
View File
@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const images = useChatImages()
const [uploadProgress, setUploadProgress] = useState(0)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
@@ -117,23 +121,38 @@ export function useCollabChat(tripId: any, currentUser: any) {
}, [])
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
images.add(incoming, ({ rejected, overflow }) => {
if (rejected) toast.error(t('collab.chat.imageRejected'))
if (overflow) toast.error(t('collab.chat.imageLimit', { max: MAX_CHAT_IMAGES }))
})
}, [images, toast, t])
const removeImage = images.remove
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
if ((!body && !images.files.length) || sending) return
setSending(true)
try {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
let data
if (images.files.length) {
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
} else {
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
data = await collabApi.sendMessage(tripId, payload)
}
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -175,5 +194,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -0,0 +1,120 @@
// FE-COMP-COLEXPORT-001 to FE-COMP-COLEXPORT-008
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionExportMenu from './CollectionExportMenu';
type MenuProps = Omit<React.ComponentProps<typeof CollectionExportMenu>, 't'>;
function Harness(props: MenuProps): React.ReactElement {
const { t } = useTranslation();
return (
<>
<CollectionExportMenu {...props} t={t} />
<button type="button">Somewhere else</button>
</>
);
}
function renderMenu(overrides: Partial<MenuProps> = {}) {
const props: MenuProps = { onExport: vi.fn(), ...overrides };
render(<Harness {...props} />);
return props;
}
const trigger = () => screen.getByRole('button', { name: 'Export' });
const items = () => screen.getAllByRole('menuitem');
describe('CollectionExportMenu (#2301)', () => {
it('FE-COMP-COLEXPORT-001: is a closed menu button until pressed', () => {
renderMenu();
expect(trigger()).toHaveAttribute('aria-haspopup', 'menu');
expect(trigger()).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-002: offers both formats, each with its extension and what it is for', async () => {
renderMenu();
await userEvent.click(trigger());
const menu = screen.getByRole('menu', { name: 'Download this list as a file' });
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
expect(trigger()).toHaveAttribute('aria-controls', menu.id);
expect(items().map(item => item.textContent)).toEqual([
'TREK list.trekcollection.jsonFor another TREK, with labels and status',
'GPX.gpxWaypoints for OsmAnd, Organic Maps, Garmin and other map apps',
]);
});
it('FE-COMP-COLEXPORT-003: exports the picked format, closes and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(props.onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
});
it('FE-COMP-COLEXPORT-004: moves focus into the menu and walks it with the arrow keys, Home and End', async () => {
renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{ArrowDown}');
expect(items()[1]).toHaveFocus();
// Past the end wraps round, as it does in every menu.
await userEvent.keyboard('{ArrowDown}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(items()[1]).toHaveFocus();
await userEvent.keyboard('{Home}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{End}');
expect(items()[1]).toHaveFocus();
});
it('FE-COMP-COLEXPORT-005: opens from the keyboard and picks with Enter', async () => {
const props = renderMenu();
trigger().focus();
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Enter}');
expect(props.onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLEXPORT-006: closes on Escape without exporting, and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
expect(props.onExport).not.toHaveBeenCalled();
});
it('FE-COMP-COLEXPORT-007: closes on a click anywhere else, and on Tab', async () => {
renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('button', { name: 'Somewhere else' }));
expect(screen.queryByRole('menu')).toBeNull();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Tab}');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-008: cannot be opened while an export is still running', async () => {
renderMenu({ exporting: true });
expect(trigger()).toBeDisabled();
await userEvent.click(trigger());
expect(screen.queryByRole('menu')).toBeNull();
});
});
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Download, FileJson, Loader2, Route, type LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../../types'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { COLLECTION_FILE_EXTENSION, COLLECTION_GPX_EXTENSION, type CollectionExportFormat } from './collectionFile'
interface CollectionExportMenuProps {
onExport: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
interface FormatOption {
format: CollectionExportFormat
icon: LucideIcon
label: string
extension: string
hint: string
}
/** Wide enough for the longest hint on one or two lines at the default text size. */
const MENU_WIDTH = 300
/**
* Export in the list hero: a menu of the two formats a list can leave in (#2301).
*
* The TREK file carries everything and is for another TREK; GPX carries the
* places as waypoints and is for every other map app. Choosing is the whole
* interaction, so it is a menu rather than a dialog.
*
* Portalled, because the hero clips its overflow, and placed against the
* trigger's right edge, where the button sits. A menu in the WAI-ARIA sense:
* focus moves into it on open, the arrow keys, Home and End walk it, Escape
* closes it and gives focus back to the button, and Tab leaves it closed.
*/
export default function CollectionExportMenu({ onExport, exporting, t }: CollectionExportMenuProps): React.ReactElement {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 150, offset: 6 })
// Focusable only once positioned: until then the menu is hidden, and a hidden
// element refuses focus.
const placed = open && anchored !== null
const options: FormatOption[] = [
{
format: 'trek', icon: FileJson, label: t('collections.file.formatTrek'),
extension: COLLECTION_FILE_EXTENSION, hint: t('collections.file.formatTrekHint'),
},
{
format: 'gpx', icon: Route, label: 'GPX',
extension: COLLECTION_GPX_EXTENSION, hint: t('collections.file.formatGpxHint'),
},
]
const items = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
const close = useCallback((refocus: boolean) => {
setOpen(false)
if (refocus) triggerRef.current?.focus()
}, [])
useEffect(() => {
if (placed) menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
}, [placed])
useEffect(() => {
if (!open) return
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
close(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open, close])
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const all = items()
const at = all.indexOf(document.activeElement as HTMLElement)
const focus = (index: number) => {
e.preventDefault()
all[(index + all.length) % all.length]?.focus()
}
if (e.key === 'ArrowDown') focus(at + 1)
else if (e.key === 'ArrowUp') focus(at - 1)
else if (e.key === 'Home') focus(0)
else if (e.key === 'End') focus(all.length - 1)
else if (e.key === 'Escape') {
e.preventDefault()
close(true)
} else if (e.key === 'Tab') close(false)
}
const pick = (format: CollectionExportFormat) => {
close(true)
onExport(format)
}
// Pinned by its right edge, under the button, so it grows leftwards into the hero.
const right = anchored ? Math.max(8, window.innerWidth - anchored.left - anchored.width) : 8
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(o => !o)}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault()
setOpen(true)
}
}}
disabled={exporting}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
{open && createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={t('collections.file.exportTitle')}
onKeyDown={onMenuKeyDown}
className="trek-popover-enter fixed z-[var(--z-toast)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-dropdown"
style={{
...(anchored?.flipped ? { bottom: anchored.bottom } : { top: anchored?.top ?? 0 }),
right,
width: MENU_WIDTH,
maxWidth: 'calc(100vw - 16px)',
visibility: anchored ? 'visible' : 'hidden',
}}
>
{options.map(option => (
<button
key={option.format}
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => pick(option.format)}
className="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
<option.icon size={16} className="mt-0.5 shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span className="text-body font-semibold text-content">{option.label}</span>
<span className="text-caption text-content-faint">{option.extension}</span>
</span>
<span className="block text-caption text-content-muted">{option.hint}</span>
</span>
</button>
))}
</div>,
document.body,
)}
</>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-015
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -160,4 +160,42 @@ describe('CollectionHero', () => {
expect(within(share).queryByText('3')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
});
// ── Export as a file (#2198) ───────────────────────────────────────────
it('FE-COMP-COLHERO-012: offers Export beside Edit and Share, in that order', async () => {
const onExport = vi.fn();
renderHero({ canEdit: true, canShare: true, isOwner: true, onExport });
const actions = ['Edit', 'Export', 'Share'].map(name => screen.getByRole('button', { name }));
// Reading order in the DOM is the reading order on screen.
const all = [...document.querySelectorAll('.col-hero-actions button')];
expect(all).toEqual(actions);
// Export asks which format first (#2301).
await userEvent.click(actions[1]);
await userEvent.click(screen.getByRole('menuitem', { name: /TREK list/ }));
expect(onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
renderHero({ onExport: undefined });
expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument();
});
it('FE-COMP-COLHERO-014: a viewer may export, since exporting reads and does not write', async () => {
const onExport = vi.fn();
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
it('FE-COMP-COLHERO-015: refuses a second click while the first export is still running', () => {
renderHero({ onExport: vi.fn(), exporting: true });
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled();
});
});
@@ -3,6 +3,8 @@ import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionExportMenu from './CollectionExportMenu'
import type { CollectionExportFormat } from './collectionFile'
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
@@ -26,14 +28,18 @@ interface CollectionHeroProps {
onEdit: () => void
shareMemberCount: number
onShare: () => void
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
onExport?: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
/**
* The page header — a colour-washed (or cover-image) glass hero that gives the
* active list an identity: an eyebrow with the sharing state + member avatars,
* the big list name, an optional description + link chips, and a Share action
* top-right. Filtering lives in the toolbar above the places, not here.
* the big list name, an optional description + link chips, and the Edit,
* Export and Share actions top-right. Filtering lives in the toolbar above the
* places, not here.
* Modelled on the dashboard hero-trip.
*/
function linkHost(url: string): string {
@@ -42,7 +48,7 @@ function linkHost(url: string): string {
export default function CollectionHero({
eyebrow, title, color, coverImage, description, links,
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, onExport, exporting, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
const showAvatars = accepted.length > 1
@@ -94,6 +100,7 @@ export default function CollectionHero({
<span className="txt">{t('common.edit')}</span>
</button>
)}
{onExport && <CollectionExportMenu onExport={onExport} exporting={exporting} t={t} />}
{canShare && (
<button
type="button"
@@ -1,4 +1,4 @@
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-011
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionPlace } from '@trek/shared';
@@ -157,4 +157,14 @@ describe('CollectionList', () => {
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-011: the row opts out of the global press-scale (#2158)', () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the row-wide role="button" shifted the status badge out from under the pointer,
// so the click retargeted onto the row and opened the place instead of cycling the
// status. The data-no-press attribute is the pin.
renderList();
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toHaveAttribute('data-no-press');
});
});
@@ -44,6 +44,10 @@ export default function CollectionList({
key={place.id}
ref={active ? selectedRef : undefined}
role="button"
// Opt out of the global :active press-scale: shrinking the row-wide button
// slides the status badge out from under the pointer mid-click, so the click
// retargets onto the row and opens the place instead of cycling (#2158).
data-no-press
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
@@ -29,7 +29,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
hoverDisabled
// Hover was off here, so a wall of round photos gave no way to tell what any of
// them were without clicking. The trip map has said what a marker is all along;
// this one adds the rating, because "which of these did I like" is exactly the
// question a small picture cannot answer.
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
// No center/zoom: the map frames itself on the collection's places at mount, and
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import EmptyState from '../shared/EmptyState'
interface LocationBias {
low: { lat: number; lng: number }
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
/**
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
*
* The panel is positioned against the FILTER ROW rather than against this
* button, so it opens across the full width of the row instead of the half its
* own trigger occupies. The two filters sit side by side and only one can be
* open at a time, so the wider panel costs nothing and stops list names being
* truncated at roughly ten characters. The row carries the `relative` this
* needs; there is no other call site.
*/
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="relative min-w-0 flex-1" ref={ref}>
<div className="min-w-0 flex-1" ref={ref}>
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
<ChevronDown size={13} className="shrink-0 text-content-faint" />
</button>
{open && (
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
]
return (
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
// Same 320px as the details column on the other side of the form: two panels
// of different widths flanking one form read as a mistake rather than a
// hierarchy, and neither of them is the more important one.
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Bookmark size={15} className="text-accent" />
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
/>
</div>
{lists.length > 0 && (
<div className="flex gap-2">
<div className="relative flex gap-2">
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
<EmptyState
scene="search"
title={t('collections.picker.empty')}
size={84}
fill
surface="var(--bg-secondary)"
/>
) : (
<div className="flex flex-col gap-1">
{page.map(place => (
@@ -1,4 +1,4 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -602,3 +602,89 @@ describe('CollectionPlaceDetail: editable address', () => {
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
});
});
// ── Cover controls sit beside the close button ───────────────────────────────
describe('CollectionPlaceDetail: cover controls', () => {
it('FE-COMP-COLDETAIL-044: the camera sits in the top right row, just left of the close button', async () => {
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
const camera = await screen.findByRole('button', { name: 'Upload image' });
const close = screen.getByRole('button', { name: 'Close' });
const row = close.parentElement as HTMLElement;
expect(camera.parentElement).toBe(row);
expect(camera.nextElementSibling).toBe(close);
// Same class as the close button, so the same round look, hover and phone size.
expect(camera).toHaveClass('col-detail-close');
// The category chip keeps the top left corner to itself.
const chip = screen.getByText('Food').closest('.col-detail-cover-cat') as HTMLElement;
expect(row.contains(chip)).toBe(false);
});
it('FE-COMP-COLDETAIL-045: with a custom cover the row reads remove, change, close and change still opens the picker', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, image_url: '/uploads/places/mock.jpg' } });
const change = await screen.findByRole('button', { name: 'Change image' });
const remove = screen.getByRole('button', { name: 'Remove image' });
const close = screen.getByRole('button', { name: 'Close' });
expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]);
expect(remove).toHaveClass('col-detail-close');
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clicked = vi.fn();
input.addEventListener('click', clicked);
await user.click(change);
expect(clicked).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-046: while an upload runs the camera spins, ignores clicks and the remove control steps aside', async () => {
const user = userEvent.setup();
const onUploadImage = vi.fn(() => new Promise<void>(() => {}));
renderDetail({ canEdit: true, onUploadImage, place: { ...place, image_url: '/uploads/places/mock.jpg' } });
await screen.findByRole('button', { name: 'Change image' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
const change = screen.getByRole('button', { name: 'Change image' });
await waitFor(() => expect(change.querySelector('.animate-spin')).not.toBeNull());
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument();
expect(change.nextElementSibling).toBe(screen.getByRole('button', { name: 'Close' }));
const clicked = vi.fn();
input.addEventListener('click', clicked);
await user.click(change);
expect(clicked).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-047: a long category shares the top bar with the controls and ellipsizes instead of running under them', async () => {
const long = 'Restaurants, cafes and late night bars';
renderDetail({
canEdit: true,
onUploadImage: vi.fn(),
place: { ...place, image_url: '/uploads/places/mock.jpg', category: { id: 1, name: long, color: '#f00', icon: null } },
});
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
const text = screen.getByText(long);
const chip = text.closest('.col-detail-cover-cat') as HTMLElement;
const bar = controls.parentElement as HTMLElement;
expect(controls.querySelectorAll('button')).toHaveLength(3);
// Chip first, controls last in one flex bar: the chip only gets what the buttons leave.
expect(Array.from(bar.children)).toEqual([chip, controls]);
expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]');
expect(controls).toHaveClass('ms-auto', 'flex-none');
expect(chip).toHaveClass('min-w-0');
expect(text).toHaveClass('truncate');
});
it('FE-COMP-COLDETAIL-048: without a category the controls still hold the end of the bar', async () => {
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, category: undefined } });
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
expect(document.querySelector('.col-detail-cover-cat')).toBeNull();
expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([controls]);
expect(controls).toHaveClass('ms-auto');
});
});
@@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
{canEdit && onUploadImage && (
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
</button>
</Tooltip>
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
<Trash2 size={15} />
</button>
</Tooltip>
{/* Chip and cover controls share the top bar. The controls keep the end and
never shrink, so a long category name ellipsizes instead of sliding under
them, however many buttons there are and whatever size phones give them. */}
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
{place.category?.name && (
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} className="flex-none" />
<span className="truncate">{place.category.name}</span>
</span>
)}
{/* The cover controls wear the close button's class, so they get its look,
hover and phone size. */}
<div className="ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button type="button" className="col-detail-close" onClick={handleImageRemove} aria-label={t('places.removeImage')}>
<Trash2 size={16} />
</button>
</Tooltip>
)}
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
className="col-detail-close"
style={imgBusy ? { cursor: 'default' } : undefined}
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
>
{imgBusy ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
</button>
</Tooltip>
</>
)}
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
</div>
)}
</div>
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
@@ -222,35 +228,12 @@ export default function CollectionPlaceDetail({
<div className="col-detail-body">
{/* Meta (view only) */}
{!editing && (place.address || navigationTargets.length > 0) && (
{!editing && place.address && (
<div className="col-detail-meta">
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
{/* Same picker as inside a trip (#1455). A saved place is somewhere
you intend to go, and until now the only way to get directions
was to add it to a trip first. */}
{navigationTargets.length > 0 && (
<>
<button
ref={navBtnRef}
type="button"
className="col-detail-nav"
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
>
<Navigation size={12} />
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</button>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
</div>
)}
@@ -258,9 +241,22 @@ export default function CollectionPlaceDetail({
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
{onRate && (
<div style={{ padding: '2px 0' }}>
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
{/* Rating and labels on one line, each on its own ground.
A bare line of stars read as another row of text, and the labels tried the
cover photo first — where a blue chip on a blue sky is simply not there.
Side by side they are two badges answering two questions about the place. */}
{(onRate || assignedLabels.length > 0) && (
<div className="col-detail-badges">
{onRate && (
<div className="col-detail-rating">
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
</div>
)}
{!editing && assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static big" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
@@ -334,15 +330,6 @@ export default function CollectionPlaceDetail({
</div>
) : (
<>
{assignedLabels.length > 0 && (
<div className="col-detail-labels">
{assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
@@ -371,6 +358,32 @@ export default function CollectionPlaceDetail({
<>
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
{/* Beside the other two rather than up by the address: getting directions is
something you DO with the place, like editing it or copying it into a
trip, and the row of things you do belongs in one place. */}
{navigationTargets.length > 0 && (
<>
<button
ref={navBtnRef}
type="button"
className="col-detail-btn"
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
>
<Navigation size={14} />
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</button>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
<div className="col-detail-footer-spacer" />
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
</>
@@ -0,0 +1,330 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-026
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { Collection, CollectionFile } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal';
import type { GpxReader } from './collectionFile';
// The modal takes `t` as a prop, so this harness forwards the real English
// translator and the assertions read as the strings a person sees.
function Harness(props: Omit<React.ComponentProps<typeof ImportCollectionModal>, 't'>) {
const { t } = useTranslation();
return <ImportCollectionModal {...props} t={t} />;
}
const listFile: CollectionFile = {
format: 'trek.collection', version: 1, name: 'Lisbon',
description: 'Worth it if you have three days',
color: '#ef4444',
labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#00ff00' }],
places: [{ name: 'Time Out Market' }, { name: 'Miradouro' }, { name: 'Pastéis de Belém' }],
} as CollectionFile;
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
const props = {
onImport: vi.fn().mockResolvedValue(undefined),
onReadGpx: vi.fn().mockRejectedValue(new Error('no GPX expected here')),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
/** Put a file into the hidden input the way a file picker would. */
async function choose(content: string, name = 'list.trekcollection.json') {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([content], name, { type: name.endsWith('.gpx') ? 'application/gpx+xml' : 'application/json' });
// jsdom's File has no usable text(); the component awaits it.
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
await userEvent.upload(input, file);
}
const importButton = () => screen.getByRole('button', { name: 'Import' });
describe('ImportCollectionModal (#2198)', () => {
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
renderModal();
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-002: shows what the file holds before anything is imported', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Worth it if you have three days')).toBeInTheDocument());
expect(screen.getByText('3 places')).toBeInTheDocument();
expect(screen.getByText('2 labels')).toBeInTheDocument();
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(importButton()).toBeEnabled();
expect(props.onImport).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-003: leaves the label count out when the file has none', async () => {
renderModal();
await choose(JSON.stringify({ ...listFile, labels: [] }));
await waitFor(() => expect(screen.getByText('3 places')).toBeInTheDocument());
expect(screen.queryByText(/labels$/)).toBeNull();
});
it('FE-COMP-COLLIMPORT-004: imports the file under its own name', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(importButton()).toBeEnabled());
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledTimes(1));
// No name argument: the file's own name was kept.
expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined);
});
it('FE-COMP-COLLIMPORT-005: imports under a name the person typed instead', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
await userEvent.clear(screen.getByDisplayValue('Lisbon'));
await userEvent.type(screen.getByRole('textbox'), 'Lisbon (from Ana)');
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.anything(), 'Lisbon (from Ana)'));
});
it('FE-COMP-COLLIMPORT-006: will not import under an empty name', async () => {
renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
await userEvent.clear(screen.getByRole('textbox'));
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-007: says what is wrong with a file it cannot use', async () => {
renderModal();
await choose('not json');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
await choose(JSON.stringify({ format: 'something.else' }));
await waitFor(() => expect(screen.getByText('That is not a TREK list file.')).toBeInTheDocument());
});
it('FE-COMP-COLLIMPORT-008: takes a good file after a bad one, and forgets the complaint', async () => {
renderModal();
await choose('not json');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
expect(screen.queryByText('That file could not be read.')).toBeNull();
});
it('FE-COMP-COLLIMPORT-009: keeps the dialog open and says why when the import fails', async () => {
const props = renderModal({ onImport: vi.fn().mockRejectedValue(new Error('the server said no')) });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(importButton()).toBeEnabled());
await userEvent.click(importButton());
await waitFor(() => expect(screen.getByText('the server said no')).toBeInTheDocument());
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(props.onClose).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-010: closes without importing on Cancel', async () => {
const props = renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onClose).toHaveBeenCalledTimes(1);
expect(props.onImport).not.toHaveBeenCalled();
});
});
describe('ImportCollectionModal with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>Pena Palace</name></wpt></gpx>';
const gpxFile: CollectionFile = {
format: 'trek.collection', version: 1, name: 'Sintra loop',
places: [{ name: 'Pena Palace' }, { name: 'Moorish Castle' }],
} as CollectionFile;
const refusal = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } });
it('FE-COMP-COLLIMPORT-011: says which files it takes', () => {
renderModal();
expect(screen.getByText('.trekcollection.json · .gpx')).toBeInTheDocument();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.accept).toContain('.gpx');
});
it('FE-COMP-COLLIMPORT-012: shows what a GPX holds, and what of it will not become a place', async () => {
const onReadGpx = vi.fn().mockResolvedValue({ file: gpxFile, skipped: 2, track_points: 8 });
const props = renderModal({ onReadGpx });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
expect(onReadGpx).toHaveBeenCalledWith(gpx, 'sintra.gpx');
expect(screen.getByText('2 places')).toBeInTheDocument();
expect(screen.getByText('2 waypoints without usable coordinates are left out.')).toBeInTheDocument();
expect(screen.getByText('This file also has 8 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
// The note about ratings and members is about TREK files; a GPX never had any.
expect(screen.queryByText(/Ratings, members/)).toBeNull();
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, undefined));
});
it('FE-COMP-COLLIMPORT-013: imports a GPX under a name the person typed', async () => {
const props = renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: gpxFile, skipped: 0, track_points: 0 }) });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
await userEvent.clear(screen.getByRole('textbox'));
await userEvent.type(screen.getByRole('textbox'), 'Sintra with the kids');
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, 'Sintra with the kids'));
expect(screen.queryByText(/track points/)).toBeNull();
});
it('FE-COMP-COLLIMPORT-014: will not make an empty list of a GPX that only has a track', async () => {
const trackOnly = { ...gpxFile, places: [] };
renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: trackOnly, skipped: 0, track_points: 120 }) });
await choose(gpx, 'track.gpx');
await waitFor(() => expect(screen.getByText('This GPX file has no waypoints, so there is nothing to import.')).toBeInTheDocument());
expect(screen.getByText('This file also has 120 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-015: says in its own words why a GPX was refused', async () => {
const onReadGpx = vi.fn()
.mockRejectedValueOnce(refusal('not-gpx'))
.mockRejectedValueOnce(refusal('too-many-places'))
.mockRejectedValueOnce(refusal('unreadable'));
renderModal({ onReadGpx });
await choose(gpx, 'a.gpx');
await waitFor(() => expect(screen.getByText('That is not a GPX file.')).toBeInTheDocument());
await choose(gpx, 'b.gpx');
await waitFor(() => expect(screen.getByText(
'That file has more than 1000 places. Split it and import the parts one at a time.',
)).toBeInTheDocument());
await choose(gpx, 'c.gpx');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-016: says it is reading while the server reads, and why it could not', async () => {
let fail: ((err: unknown) => void) | null = null;
const onReadGpx = vi.fn<GpxReader>(() => new Promise((_, reject) => { fail = reject; }));
renderModal({ onReadGpx });
await choose(gpx, 'slow.gpx');
await waitFor(() => expect(screen.getByText('Reading the file…')).toBeInTheDocument());
fail!(Object.assign(new Error('x'), { response: { status: 503, data: { error: 'The server is busy' } } }));
await waitFor(() => expect(screen.getByText('The server is busy')).toBeInTheDocument());
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
});
});
// ── The same file into a list that is already there (#2301 follow-up) ────────
describe('ImportCollectionModal into an existing list', () => {
const lists = [
{ id: 7, owner_id: 1, name: 'Lisbon 2027', color: '#22c55e', place_count: 4 },
{ id: 9, owner_id: 1, name: 'Porto', color: '#3b82f6', place_count: 2 },
] as Collection[];
const intoProps = (over: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) =>
renderModal({ onImportInto: vi.fn().mockResolvedValue(undefined), lists, ...over });
it('FE-COMP-COLLIMPORT-020: offers the choice only once a file has been read', async () => {
intoProps();
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
expect(screen.getByText('New list')).toBeInTheDocument();
// A new list stays the default, so nothing changes for anyone who ignores it.
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(importButton()).toBeEnabled();
});
it('FE-COMP-COLLIMPORT-021: keeps to a new list when there is nothing to add to', async () => {
renderModal({ onImportInto: vi.fn(), lists: [] });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-022: adds the file to the list that was picked, and asks for no name', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.queryByDisplayValue('Lisbon')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Porto'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), 9));
expect(props.onImport).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-023: starts on the list that is open, whatever order they come in', async () => {
const props = intoProps({ defaultListId: 9 });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.anything(), 9));
});
it('FE-COMP-COLLIMPORT-024: says what adding to a list does and does not do', async () => {
intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.getByText(/Places the list already has stay as they are/)).toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-025: keeps the dialog open and says why when adding fails', async () => {
const onImportInto = vi.fn().mockRejectedValue(
Object.assign(new Error('x'), { response: { status: 403, data: { error: 'You have read-only access to this list' } } }),
);
intoProps({ onImportInto });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(screen.getByText('You have read-only access to this list')).toBeInTheDocument());
});
it('FE-COMP-COLLIMPORT-026: goes back to a new list with the name it started with', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByText('New list'));
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined));
expect(props.onImportInto).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,373 @@
import React, { useMemo, useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
import {
readCollectionFile,
type CollectionFileError,
type GpxLeftovers,
type GpxReader,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
} from './collectionFile'
interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void>
/** Adds the file to a list that is already there. Without it the dialog only makes new ones. */
onImportInto?: (file: CollectionFile, collectionId: number) => Promise<void>
/** The lists this person may add to; the server applies the same rule again. */
lists?: Collection[]
/** The list that is open, so the obvious target is the one already selected. */
defaultListId?: number | null
/** Reads a GPX into a list file (#2301); the server does the parsing. */
onReadGpx: GpxReader
onClose: () => void
t: TranslationFn
}
/** Keyed by the error type, so a new way for a file to fail does not build without its words. */
const ERROR_KEYS: Record<CollectionFileError, string> = {
'too-large': 'collections.file.errorTooLarge',
unreadable: 'collections.file.errorUnreadable',
'not-a-collection': 'collections.file.errorNotACollection',
'not-gpx': 'collections.file.errorNotGpx',
'too-many-places': 'collections.file.errorTooManyPlaces',
}
/** What a GPX held besides its places, said before anything is imported. */
function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; placeCount: number; t: TranslationFn }) {
const notes = [
placeCount === 0 && t('collections.file.gpxEmpty'),
leftovers.skipped > 0 && t('collections.file.gpxSkipped', { count: leftovers.skipped }),
leftovers.trackPoints > 0 && t('collections.file.gpxTrack', { count: leftovers.trackPoints }),
].filter(Boolean)
if (notes.length === 0) return null
return (
<ul className="flex flex-col gap-1">
{notes.map(note => (
<li key={note as string} className="flex items-start gap-2 text-[12px] text-content-muted">
<Info size={13} className="shrink-0 mt-0.5 text-content-faint" />
<span>{note}</span>
</li>
))}
</ul>
)
}
/** One of the two answers to "where do these places go", as a card you can tap. */
function TargetCard({ active, icon: Icon, title, hint, onClick }: {
active: boolean
icon: typeof Plus
title: string
hint: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 px-3 py-3 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center ${active ? 'bg-accent text-accent-text' : 'bg-surface-input text-content-muted'}`}>
<Icon size={15} />
</span>
<span className="text-[13px] font-semibold text-content">{title}</span>
<span className="text-[11.5px] text-content-faint leading-snug">{hint}</span>
{active && (
<span className="absolute top-2 right-2 w-4 h-4 rounded-full bg-accent text-accent-text flex items-center justify-center">
<Check size={11} strokeWidth={3} />
</span>
)}
</button>
)
}
/** The lists the file may be added to, the open one first. */
function ListChoice({ lists, selectedId, onSelect, t }: {
lists: Collection[]
selectedId: number | null
onSelect: (id: number) => void
t: TranslationFn
}) {
const [search, setSearch] = useState('')
const shown = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
return (
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.file.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
<div className="flex flex-col gap-1 max-h-[34vh] overflow-y-auto -mx-1 px-1">
{shown.map(list => {
const active = list.id === selectedId
return (
<button
key={list.id}
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge bg-surface-card hover:bg-surface-hover'
}`}
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{active && <Check size={16} className="shrink-0 text-accent" />}
</button>
)
})}
{shown.length === 0 && (
<p className="text-center text-[13px] text-content-faint py-6">{t('collections.noOtherLists')}</p>
)}
</div>
</div>
)
}
/**
* Read a list file or a GPX and make a list of it (#2198, #2301).
*
* Two steps on purpose. A file from somebody else is an unknown quantity, so
* it is read and shown first — how many places, which labels, what the list is
* called — and only then imported. The name is editable in the same breath,
* because a file called "Lisbon" from a friend is usually worth calling
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
* the list editor.
*
* A list file never leaves the browser as a file: it is parsed here. A GPX is
* read by the server into the same kind of list file, and from then on the two
* are one path: what goes to the import is a list file, through the same
* contract the server validates against.
*
* The file can go into a list that is already there instead of a new one. That
* is a choice rather than the default, because the two do different things: a
* new list is the file as it stands, while adding to a list leaves everything
* in it alone and skips the places it already has.
*/
export default function ImportCollectionModal({
onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t,
}: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
const [name, setName] = useState('')
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [reading, setReading] = useState(false)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const [target, setTarget] = useState<'new' | 'existing'>('new')
// The open list first: it is the one somebody importing from inside a list means.
const targets = useMemo(() => {
const all = lists ?? []
const open = all.find(l => l.id === defaultListId)
return open ? [open, ...all.filter(l => l.id !== open.id)] : all
}, [lists, defaultListId])
const [listId, setListId] = useState<number | null>(() => targets[0]?.id ?? null)
const canChoose = !!onImportInto && targets.length > 0
const intoExisting = canChoose && target === 'existing'
const take = async (chosen: File | undefined) => {
if (!chosen || reading) return
setError(null)
setFailedMessage(null)
setReading(true)
try {
const result = await readCollectionFile(chosen, onReadGpx)
setFile(result.file)
setLeftovers(result.gpx ?? null)
setError(result.error)
if (result.file) setName(result.file.name)
} catch (err) {
setFile(null)
setLeftovers(null)
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
} finally {
setReading(false)
}
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
void take(e.dataTransfer.files?.[0])
}
const submit = async () => {
if (!file || busy) return
setBusy(true)
setError(null)
setFailedMessage(null)
try {
if (intoExisting && listId != null) {
await onImportInto!(file, listId)
} else {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
}
} catch (err) {
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
} finally {
setBusy(false)
}
}
const errorText = error === 'failed'
? (failedMessage ?? t('common.error'))
: error ? t(ERROR_KEYS[error], { count: MAX_COLLECTION_FILE_PLACES }) : null
// A GPX of nothing but a track would make an empty list, which is never what was meant.
const nothingToImport = !!leftovers && !!file && file.places.length === 0
return (
<Modal
isOpen
onClose={onClose}
title={t('collections.file.importTitle')}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-[13px] font-medium text-content-muted hover:bg-surface-hover transition-colors">
{t('common.cancel')}
</button>
<button
type="button"
onClick={submit}
disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button>
</div>
}
>
<div className="flex flex-col gap-3">
<input
ref={inputRef}
type="file"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
{!file ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
disabled={reading}
aria-busy={reading}
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
{reading ? <Loader2 size={22} className="text-content-faint animate-spin" /> : <Upload size={22} className="text-content-faint" />}
<span className="text-[13px] font-medium text-content">
{reading ? t('collections.file.reading') : t('collections.file.choose')}
</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION} · {COLLECTION_GPX_EXTENSION}</span>
</button>
) : (
<>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
{leftovers ? <Route size={15} /> : <FileJson size={15} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
<span className="flex items-center gap-3 text-[11.5px] text-content-faint">
<span className="inline-flex items-center gap-1"><MapPin size={11} />{t('collections.placeCount', { count: file.places.length })}</span>
{file.labels && file.labels.length > 0 && (
<span className="inline-flex items-center gap-1"><Tag size={11} />{t('collections.file.labelCount', { count: file.labels.length })}</span>
)}
</span>
</span>
<button type="button" onClick={() => inputRef.current?.click()} className="text-[12px] font-medium text-accent shrink-0 hover:underline">
{t('collections.file.change')}
</button>
</div>
{file.description && (
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
)}
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
{canChoose && (
<div className="grid grid-cols-2 gap-2">
<TargetCard
active={target === 'new'}
icon={Plus}
title={t('collections.file.targetNew')}
hint={t('collections.file.targetNewHint')}
onClick={() => setTarget('new')}
/>
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{intoExisting ? (
<>
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className="text-[11.5px] text-content-faint">{t('collections.file.intoHint')}</p>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
</>
)}
{errorText && (
<p className="flex items-start gap-2 text-[12px] text-danger">
<AlertCircle size={13} className="shrink-0 mt-0.5" />
<span>{errorText}</span>
</p>
)}
</div>
</Modal>
)
}
@@ -35,6 +35,7 @@ function renderRail(overrides: Partial<RailProps> = {}) {
incomingInvites: [],
onSelect: vi.fn(),
onNewList: vi.fn(),
onImportList: vi.fn(),
onAcceptInvite: vi.fn(),
onDeclineInvite: vi.fn(),
...overrides,
@@ -93,7 +94,7 @@ describe('ListsRail', () => {
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
const { unmount } = render(<Harness {...{
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
onSelect: vi.fn(), onNewList: vi.fn(), onImportList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
}} />);
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
unmount();
@@ -127,4 +128,18 @@ describe('ListsRail', () => {
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
});
it('FE-COMP-LISTSRAIL-011: offers Import beside New list, and it opens the file dialog (#2198)', async () => {
const user = userEvent.setup();
const props = renderRail();
const importBtn = screen.getByRole('button', { name: 'Import a list from a file' });
await user.click(importBtn);
expect(props.onImportList).toHaveBeenCalledTimes(1);
expect(props.onNewList).not.toHaveBeenCalled();
// New list keeps its label; import is the icon beside it.
expect(screen.getByRole('button', { name: 'New list' })).toHaveTextContent('New list');
expect(importBtn).toHaveTextContent('');
});
});
@@ -1,5 +1,5 @@
import React from 'react'
import { Plus, Layers, Users } from 'lucide-react'
import { Plus, Layers, Users, Upload } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
@@ -12,6 +12,8 @@ interface ListsRailProps {
incomingInvites: IncomingCollectionInvite[]
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
/** Read a list file as a new list (#2198). */
onImportList: () => void
onAcceptInvite: (id: number) => void
onDeclineInvite: (id: number) => void
t: TranslationFn
@@ -30,7 +32,7 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
}
/**
* Left rail of the user's lists: a "New list" action, the "All saved" union
* Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union
* pseudo-list, owned lists (colour dot + count), a shared section, and an
* incoming-invites block. Selecting a list makes it active; editing / deleting
* happens from the Edit button in the hero of the active list.
@@ -38,14 +40,27 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t,
} = props
return (
<>
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
<div className="col-rail-newrow">
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
{/* Import sits beside New rather than in the hero: a file becomes a NEW
list, so it belongs where lists are made, not on the one open. */}
<button
type="button"
onClick={onImportList}
className="col-rail-new col-rail-import"
aria-label={t('collections.file.importButton')}
title={t('collections.file.importTitle')}
>
<Upload size={15} />
</button>
</div>
<div className="col-row">
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
@@ -0,0 +1,198 @@
// FE-COLL-FILE-001 to FE-COLL-FILE-020
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
collectionFileName,
downloadCollectionFile,
downloadCollectionGpx,
readCollectionFile,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
type GpxReader,
} from './collectionFile'
import type { CollectionFile } from '@trek/shared'
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
format: 'trek.collection', version: 1, name: 'Lisbon', places: [{ name: 'Time Out Market' }], ...over,
} as CollectionFile)
/** A File whose text() the jsdom build does not provide on its own. */
function asFile(content: string, size = content.length, name = 'list.trekcollection.json'): File {
return { size, name, text: () => Promise.resolve(content) } as unknown as File
}
/** A reader that must not be reached: a list file is never sent to the server. */
const noGpx: GpxReader = () => { throw new Error('a list file went to the GPX reader') }
/** A rejection the way axios delivers the server's refusal. */
const refused = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } })
describe('collectionFileName', () => {
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
expect(collectionFileName('Wochenende in Rom!')).toBe('wochenende-in-rom' + COLLECTION_FILE_EXTENSION)
expect(collectionFileName('東京')).toBe('collection' + COLLECTION_FILE_EXTENSION)
})
it('FE-COLL-FILE-002: caps a very long name rather than producing an unusable file name', () => {
const name = collectionFileName('a'.repeat(200))
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
})
it('FE-COLL-FILE-013: names a GPX the same way, with its own extension', () => {
expect(collectionFileName('Wochenende in Rom!', COLLECTION_GPX_EXTENSION)).toBe('wochenende-in-rom.gpx')
})
})
describe('downloads', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
/** Captures what would be downloaded, and under which name. */
function captureDownload() {
vi.useFakeTimers()
const revoke = vi.fn()
const created: Blob[] = []
const names: string[] = []
vi.stubGlobal('URL', {
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
revokeObjectURL: revoke,
})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.href).toContain('blob:')
names.push(this.download)
})
return { revoke, created, names, click }
}
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
const { revoke, created, names, click } = captureDownload()
downloadCollectionFile(file())
expect(click).toHaveBeenCalledTimes(1)
expect(names).toEqual(['lisbon' + COLLECTION_FILE_EXTENSION])
expect(created[0].type).toBe('application/json')
expect(document.querySelector('a')).toBeNull()
expect(revoke).not.toHaveBeenCalled()
vi.runAllTimers()
expect(revoke).toHaveBeenCalledWith('blob:x')
})
it('FE-COLL-FILE-014: hands the browser a GPX under the list name', async () => {
const { created, names } = captureDownload()
downloadCollectionGpx('Lisbon', '<gpx/>')
expect(names).toEqual(['lisbon.gpx'])
expect(created[0].type).toBe('application/gpx+xml')
expect(await created[0].text()).toBe('<gpx/>')
})
})
describe('readCollectionFile', () => {
it('FE-COLL-FILE-004: reads one of ours', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file())), noGpx)
expect(result.error).toBeNull()
expect(result.file?.name).toBe('Lisbon')
expect(result.gpx).toBeUndefined()
})
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
const text = vi.fn()
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
const result = await readCollectionFile(huge, noGpx)
expect(result).toEqual({ file: null, error: 'too-large' })
expect(text).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
expect(await readCollectionFile(asFile('not json at all'), noGpx)).toEqual({ file: null, error: 'unreadable' })
})
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
expect(await readCollectionFile(asFile(content), noGpx), content).toEqual({ file: null, error: 'not-a-collection' })
}
})
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toEqual([])
})
it('FE-COLL-FILE-010: keeps a place the envelope does not check, for the server to judge', async () => {
// The envelope counts places; each one is validated where it is written.
const result = await readCollectionFile(asFile(JSON.stringify(file({
places: [{ name: 'Fine' }, { nonsense: true }] as never,
}))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toHaveLength(2)
})
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })), noGpx)
expect(result.error).toBeNull()
expect(result.file?.version).toBe(99)
})
})
describe('readCollectionFile with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>A</name></wpt></gpx>'
it('FE-COLL-FILE-015: sends a GPX to the reader with its file name and keeps what did not become a place', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file({ name: 'Favourites' }), skipped: 2, track_points: 40 })
const result = await readCollectionFile(asFile(gpx, gpx.length, 'favourites.gpx'), read)
expect(read).toHaveBeenCalledWith(gpx, 'favourites.gpx')
expect(result).toEqual({ file: expect.objectContaining({ name: 'Favourites' }), error: null, gpx: { skipped: 2, trackPoints: 40 } })
})
it('FE-COLL-FILE-016: knows a GPX by what it is, not by what it is called', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file(), skipped: 0, track_points: 0 })
// A byte-order mark and a blank line before the declaration, under a .xml name.
await readCollectionFile(asFile(String.fromCharCode(0xfeff) + '\n ' + gpx, 100, 'export.xml'), read)
expect(read).toHaveBeenCalledTimes(1)
})
it('FE-COLL-FILE-017: holds a GPX to the same size limit, before reading it', async () => {
const read = vi.fn<GpxReader>()
expect(await readCollectionFile(asFile(gpx, 1024 * 1024 + 1, 'big.gpx'), read)).toEqual({ file: null, error: 'too-large' })
expect(read).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-018: turns the reason the server refused a GPX into the dialog\'s own', async () => {
for (const code of ['unreadable', 'not-gpx', 'too-many-places', 'too-large'] as const) {
const read = vi.fn<GpxReader>().mockRejectedValue(refused(code))
expect(await readCollectionFile(asFile(gpx), read), code).toEqual({ file: null, error: code, gpx: null })
}
})
it('FE-COLL-FILE-019: passes on a failure that was not about the file', async () => {
const offline = vi.fn<GpxReader>().mockRejectedValue(new Error('Network Error'))
await expect(readCollectionFile(asFile(gpx), offline)).rejects.toThrow('Network Error')
const unknownCode = vi.fn<GpxReader>().mockRejectedValue(refused('something-new'))
await expect(readCollectionFile(asFile(gpx), unknownCode)).rejects.toThrow('Request failed')
})
it('FE-COLL-FILE-020: says a file is unreadable when its text cannot be had at all', async () => {
const broken = { size: 10, name: 'x.gpx', text: () => Promise.reject(new Error('gone')) } as unknown as File
expect(await readCollectionFile(broken, noGpx)).toEqual({ file: null, error: 'unreadable' })
})
})
@@ -0,0 +1,141 @@
import {
COLLECTION_GPX_PROBLEMS,
collectionFileSchema,
MAX_COLLECTION_FILE_BYTES,
type CollectionFile,
type CollectionGpxProblem,
type CollectionGpxReadResult,
} from '@trek/shared'
/**
* The browser half of list export and import (#2198, #2301).
*
* Both files are built by the server, which is also where the decision about
* what may leave the instance lives (`collection-file.schema.ts`). This module
* only hands one to the browser as a download, and reads a chosen file back
* into something the import request will accept.
*
* A file that arrives here is a stranger's and is treated as one. A list file
* is read locally, never uploaded, parsed rather than evaluated, size-capped
* before parsing, and put through the shared contract so a field this build
* does not know is dropped instead of travelling on. A GPX is size-capped the
* same way and then sent as text to the server, which reads it with the XML
* library the trip importer already uses and hands back a list file: one side
* that parses XML, and it is the one that enforces the limits.
*/
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
export const COLLECTION_GPX_EXTENSION = '.gpx'
/** The two ways a list can leave: whole, for another TREK, or as waypoints for everything else. */
export type CollectionExportFormat = 'trek' | 'gpx'
/** A file name from a list name: lowercase, ascii-ish, short. */
export function collectionFileName(name: string, extension = COLLECTION_FILE_EXTENSION): string {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
return `${slug || 'collection'}${extension}`
}
function download(content: string, type: string, fileName: string): void {
const url = URL.createObjectURL(new Blob([content], { type }))
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
// Revoked on the next tick: revoking synchronously can cancel the download
// in some browsers before it has started reading the blob.
setTimeout(() => URL.revokeObjectURL(url), 0)
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
download(JSON.stringify(file, null, 2), 'application/json', collectionFileName(file.name))
}
/** Hand a list's GPX to the browser as a download, named like its list file would be. */
export function downloadCollectionGpx(listName: string, gpx: string): void {
download(gpx, 'application/gpx+xml', collectionFileName(listName, COLLECTION_GPX_EXTENSION))
}
export type CollectionFileError = 'not-a-collection' | CollectionGpxProblem
/** What a GPX held that did not become a place. */
export interface GpxLeftovers {
/** Waypoints without usable coordinates. */
skipped: number
/** Track points and unnamed route points: lines, not places. */
trackPoints: number
}
/**
* Exactly one of `file` and `error` is set; `gpx` only when the file was one.
*
* Nullable fields rather than a discriminated union on an `ok` flag: the
* client compiles with `strict: false`, and without `strictNullChecks` a
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
* failure branch would need a cast.
*/
export interface ParsedCollectionFile {
file: CollectionFile | null
error: CollectionFileError | null
gpx?: GpxLeftovers | null
}
/** Reads a GPX document into a list file. The server does that, so the caller supplies it. */
export type GpxReader = (gpx: string, fileName: string) => Promise<CollectionGpxReadResult>
/** A list file is JSON and JSON never opens with `<`, so this is all the sniffing a GPX needs. */
function looksLikeXml(text: string): boolean {
const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
return withoutBom.trimStart().startsWith('<')
}
/** The reason the server gave for refusing a GPX, when it gave one this build knows. */
function gpxProblemOf(err: unknown): CollectionGpxProblem | null {
const code: unknown = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code
return COLLECTION_GPX_PROBLEMS.find(problem => problem === code) ?? null
}
async function readGpx(text: string, fileName: string, reader: GpxReader): Promise<ParsedCollectionFile> {
try {
const result = await reader(text, fileName)
return { file: result.file, error: null, gpx: { skipped: result.skipped, trackPoints: result.track_points } }
} catch (err) {
const problem = gpxProblemOf(err)
// No code means the failure was not the file's (offline, a 500), and that
// is for the dialog to report as it reports a failed import.
if (!problem) throw err
return { file: null, error: problem, gpx: null }
}
}
/**
* Read a chosen file into a list file, or say why it is not one.
*
* The failures are kept apart because they are different things a person can
* do something about: a file too big to be one of ours, a file that is not
* readable at all, a JSON that is not a TREK list, an XML that is not a GPX,
* and a GPX with more places than a list may hold.
*/
export async function readCollectionFile(file: File, readGpxFile: GpxReader): Promise<ParsedCollectionFile> {
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
let text: string
try {
text = await file.text()
} catch {
return { file: null, error: 'unreadable' }
}
if (looksLikeXml(text)) return readGpx(text, file.name ?? '', readGpxFile)
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return { file: null, error: 'unreadable' }
}
const parsed = collectionFileSchema.safeParse(raw)
return parsed.success
? { file: parsed.data, error: null }
: { file: null, error: 'not-a-collection' }
}
@@ -0,0 +1,568 @@
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-026
/**
* The review step between a detector's guess and a row in somebody's trip.
*
* This dialog is the only thing standing between Dawarich's visit detector and
* real data, and almost all of its behaviour is invisible until it is wrong:
* which fields are prefilled, which of them are sent back, and which are left
* out because the user did not actually change them. The server takes the body
* at face value (an accidental `name` or `date` in it silently overrides the
* recording), so "only what was corrected travels" is the contract worth
* pinning, and it is the one a refactor breaks first.
*
* The rest is the shape of the three targets. `place` needs a trip and offers a
* day (and no date: the day IS where it lands, and the server reads no date for
* a place), `journal` needs a journey, a date and a story, `bucket_list` needs a
* matched wish and hides the clock entirely because ticking a wish off uses the
* recorded timestamp and nothing else. Each of the three can be in a state
* where there is nothing to write to (no trips, no journeys, no matched wish),
* and in each of those the confirm button has to be inert rather than sending a
* body the server will reject with a 400.
*
* The seeding is keyed on the stay, not on the target, and one live instance
* serves all three rails. That makes two things true at once that are easy to
* get wrong together: what the user typed survives a switch from the place rail
* to the journal rail, and the trip and day they chose under the place rail
* must not travel in the journal body they then confirm.
*
* Both shells are covered: the phone gets an MSheet with its own footer, the
* desktop a Modal, and the fields in between are the same element tree. A
* regression that dropped one shell would otherwise only show up on a device.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent } from '@testing-library/react'
import type { DawarichSuggestion } from '@trek/shared'
import { render, screen } from '../../../tests/helpers/render'
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
// The one thing the dialog reads about the device. A hoisted flag rather than a
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
const phone = vi.hoisted(() => ({ value: false }))
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
/**
* A stay as the suggestions endpoint hands it over. The offsets are real ones:
* `clockOf` slices the local wall clock straight out of the string rather than
* converting it, so a fixture in UTC would quietly test a different code path
* than the one production sees.
*/
function stay(over: Partial<DawarichSuggestion> = {}): DawarichSuggestion {
return {
id: 1,
sourceVisitId: '1',
tripId: null,
tripTitle: null,
name: 'Cafe Reichard',
lat: 50.94,
lng: 6.96,
startedAt: '2026-09-10T10:15:00+02:00',
endedAt: '2026-09-10T12:40:00+02:00',
durationMinutes: 145,
localDate: '2026-09-10',
sourceStatus: 'suggested',
confidence: null,
confidenceBand: null,
state: 'new',
target: null,
acceptedPlaceId: null,
acceptedJournalEntryId: null,
acceptedBucketListItemId: null,
sourceChanged: false,
sourceMissing: false,
matchedBucketListItemId: null,
matchedBucketListName: null,
countryCode: null,
firstSeenAt: '2026-09-10T06:00:00Z',
lastSeenAt: '2026-09-10T06:00:00Z',
...over,
}
}
const TRIPS: AcceptTargetOption[] = [
{ id: 31, label: 'Rhineland' },
{ id: 32, label: 'Alps' },
]
// Distinct labels per trip so "the day list followed the trip" is observable
// without reading ids out of the DOM.
const DAYS: Record<number, AcceptTargetOption[]> = {
31: [
{ id: 401, label: 'Day 1', badge: '10 Sept' },
{ id: 402, label: 'Day 2', badge: '11 Sept' },
],
32: [{ id: 501, label: 'Alpine day', badge: '01 Oct' }],
}
const daysForTrip = (tripId: number): AcceptTargetOption[] => DAYS[tripId] ?? []
const JOURNALS: AcceptTargetOption[] = [
{ id: 71, label: 'Rhine notebook' },
{ id: 72, label: 'Alpine notebook' },
]
type Props = React.ComponentProps<typeof DawarichAcceptDialog>
function open(props: Partial<Props> = {}) {
const onConfirm = vi.fn()
const onCancel = vi.fn()
const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel }
const view = render(<DawarichAcceptDialog {...all} />)
return {
...view,
onConfirm,
onCancel,
/** Re-render with the same props bar the ones named: the parent's own update path. */
update: (next: Partial<Props>) => view.rerender(<DawarichAcceptDialog {...all} {...next} />),
}
}
/** Open a CustomSelect by its trigger, then pick the option with this label. */
function pick(triggerName: string | RegExp, optionLabel: string): void {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
fireEvent.click(screen.getByText(optionLabel))
}
/** Type into the date field's keyboard entry, which takes ISO as-is. */
function typeDate(iso: string): void {
fireEvent.click(screen.getByRole('button', { name: 'Enter date manually' }))
const input = screen.getByLabelText('Enter date manually')
fireEvent.change(input, { target: { value: iso } })
fireEvent.keyDown(input, { key: 'Enter' })
}
beforeEach(() => {
phone.value = false
})
describe('DawarichAcceptDialog', () => {
it('FE-DAWARICH-ACCEPT-001: nothing is on screen while no stay is pending', () => {
open({ suggestion: null, trips: TRIPS })
// The panel portals to document.body, so "empty container" proves nothing:
// the absence of the title and the confirm button does.
expect(screen.queryByText('Add this stay as a place')).toBeNull()
expect(screen.queryByRole('button', { name: 'Add place' })).toBeNull()
})
it('FE-DAWARICH-ACCEPT-002: every field opens prefilled from the recording', () => {
open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
// The wall clock out of the offset the recording carries, not UTC.
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
expect(screen.getByLabelText('Notes')).toHaveValue('')
expect(screen.getByText(/^Recorded /)).toBeInTheDocument()
expect(screen.getByText('145 min')).toBeInTheDocument()
// No trip of its own, so the first trip the user may write to.
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-003: an untouched stay sends only the target, its trip and the recorded clock', () => {
const { onConfirm } = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
// No name, no notes, no date: the server falls back to the recording for
// each of those, and repeating them would turn every accept into an edit.
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-004: every correction travels, the day included', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
pick('Not on a day yet', 'Day 1')
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
// The day is the only calendar fact a place carries: there is no date in
// this body because there is no date field on this rail.
expect(onConfirm).toHaveBeenCalledWith({
target: 'place',
tripId: 31,
dayId: 401,
name: 'Café Reichard am Dom',
notes: 'Two hours and a lot of cake.',
time: '09:00',
endTime: '11:45',
})
})
it('FE-DAWARICH-ACCEPT-005: a name or note that says nothing new is left out of the body', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
const untouched = { target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }
// Emptied entirely: the recording's own name is what should survive.
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
// Retyped with padding around it: after trimming this is the same name, and
// sending it would write a "correction" nobody made.
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Cafe Reichard ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
})
it('FE-DAWARICH-ACCEPT-006: unplanned is an answer, not a missing one', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 1')
// The date rides along as the trigger's badge rather than inside its label.
expect(screen.getByRole('button', { name: /Day 1/ })).toHaveTextContent('10 Sept')
// Back to "not on a day yet": the sentinel option has to clear the choice
// rather than be stored as a day id of its own.
pick(/Day 1/, 'Not on a day yet')
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-007: changing the trip drops the day that belonged to the old one', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 2')
pick('Rhineland', 'Alps')
// Day 2 is a day of the Rhineland trip; keeping it would attach the place to
// somebody else's day, which the server refuses with a 400.
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
expect(screen.getByText('Alpine day')).toBeInTheDocument()
expect(screen.queryByText('Day 2')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-008: the trip falls back from the stay to the first writable one to nothing', () => {
// The stay's own trip wins: it is the one whose dates the recording fell into.
const own = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
own.unmount()
const first = open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
first.unmount()
// No trip at all: the picker admits it and the confirm is inert, because a
// place with no trip is a 400 rather than a partial success.
open({ trips: [], daysForTrip })
expect(screen.getByRole('button', { name: 'Choose a trip' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-009: with no day resolver the day picker offers only "not on a day yet"', () => {
// The trip rail passes a resolver; an Atlas-side or MCP-shaped caller need
// not, and the picker has to survive that rather than throw.
open({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
expect(screen.getAllByText('Not on a day yet')).toHaveLength(2)
expect(screen.queryByText('Day 1')).toBeNull()
})
it('FE-DAWARICH-ACCEPT-010: the journal target asks for a story and sends the chosen journey', () => {
const { onConfirm } = open({ target: 'journal', journals: JOURNALS })
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
// A story gets room to be one; notes on a place do not need five rows.
expect(screen.getByLabelText('Your story')).toHaveAttribute('rows', '5')
expect(screen.getByText('Add photos to the entry after it is created.')).toBeInTheDocument()
// No trip or day rail here: the entry belongs to a journey, not to a day.
expect(screen.queryByText('Day')).toBeNull()
expect(screen.queryByText('Trip')).toBeNull()
fireEvent.change(screen.getByLabelText('Your story'), { target: { value: 'Cake, then the cathedral.' } })
pick('Rhine notebook', 'Alpine notebook')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'journal',
journalId: 72,
notes: 'Cake, then the cathedral.',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-011: with no journey to write to, the journal confirm is inert', () => {
open({ target: 'journal' })
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-012: ticking a wish off states the rule and sends the matched wish', () => {
const { onConfirm } = open({
target: 'bucket_list',
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
})
expect(screen.getByRole('heading', { name: 'Tick off a wish' })).toBeInTheDocument()
// Why a stay at a café is being offered against a museum: the rule, spelled out.
expect(screen.getByText(/On your wishlist: Museum Ludwig/)).toHaveTextContent('within 250 m and after 20 minutes')
// Nothing here is editable but the wish it ticks off: the visit's own
// timestamp is what gets stored, so a clock or a note would be theatre.
expect(screen.queryByLabelText('Arrived')).toBeNull()
expect(screen.queryByLabelText('Left')).toBeNull()
expect(screen.queryByLabelText('Notes')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'bucket_list',
bucketListItemId: 88,
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-013: a stay that matched no wish cannot be ticked off', () => {
open({ target: 'bucket_list', suggestion: stay() })
expect(screen.getByRole('button', { name: 'Tick it off' })).toBeDisabled()
expect(screen.queryByText(/On your wishlist/)).toBeNull()
})
it('FE-DAWARICH-ACCEPT-014: a write already in flight blocks a second one', () => {
// Everything is answered, so only `busy` can be holding the button, which is
// the point: double-accepting a stay creates two places out of one recording.
open({ trips: TRIPS, daysForTrip, busy: true })
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-015: backing out writes nothing, by button or by Escape', () => {
const { onCancel, onConfirm } = open({ trips: TRIPS, daysForTrip })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
it("FE-DAWARICH-ACCEPT-016: the source's own verdict is shown only while it is unconfirmed", () => {
const unconfirmed = open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'suggested' }) })
expect(screen.getByText('Detected, unconfirmed')).toBeInTheDocument()
unconfirmed.unmount()
// Confirmed in Dawarich: saying so would be noise, the badge is simply gone.
open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'confirmed' }) })
expect(screen.queryByText('Detected, unconfirmed')).toBeNull()
})
it('FE-DAWARICH-ACCEPT-017: only the bands Dawarich actually emits are shown, and only "low" warns', () => {
const low = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'low', confidence: 20 }) })
expect(screen.getByText('Uncertain detection').parentElement?.className).toContain('text-warning')
low.unmount()
const high = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'high', confidence: 90 }) })
// A confident detection is a fact, not a warning. Same badge, neutral tone.
expect(screen.getByText('Confident detection').parentElement?.className).toContain('text-content-secondary')
high.unmount()
const none = open({ trips: TRIPS, suggestion: stay({ confidenceBand: null }) })
expect(screen.queryByText(/detection$/)).toBeNull()
none.unmount()
// A band a later Dawarich invents: nothing at all, rather than a raw key.
open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'certain', confidence: 100 }) })
expect(screen.queryByText(/dawarich\.confidence/)).toBeNull()
expect(screen.queryByText(/detection$/)).toBeNull()
})
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
// answers with an empty string rather than a guess, and an empty string must
// not reach the body as `time: ""` or `date: ""`. On the journal rail,
// because that is the one rail that shows the date beside the clock.
const { onConfirm } = open({
target: 'journal',
journals: JOURNALS,
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
})
expect(screen.getByLabelText('Arrived')).toHaveValue('')
expect(screen.getByLabelText('Left')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'journal', journalId: 71 })
})
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
const { update } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Edited by hand' } })
// A fresh object for the same row: a parent re-render, not a new decision.
// Re-seeding here would eat what the user typed mid-sentence.
update({ suggestion: stay() })
expect(screen.getByLabelText('Name')).toHaveValue('Edited by hand')
update({
suggestion: stay({
id: 2,
name: 'Kölner Dom',
tripId: 32,
localDate: '2026-09-11',
startedAt: '2026-09-11T08:00:00+02:00',
endedAt: '2026-09-11T09:30:00+02:00',
}),
})
expect(screen.getByLabelText('Name')).toHaveValue('Kölner Dom')
expect(screen.getByLabelText('Arrived')).toHaveValue('08:00')
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-020: on a phone it is a sheet, and its footer confirms', () => {
phone.value = true
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('dialog', { name: 'Add this stay as a place' })).toBeInTheDocument()
// The desktop modal's own heading is the tell that the wrong shell rendered.
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Reichard' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'place',
tripId: 31,
name: 'Reichard',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-021: the phone sheet backs out from its header and its footer, and blocks an impossible write', () => {
phone.value = true
const { onCancel, onConfirm } = open({ target: 'journal' })
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
// Two ways out of the sheet, both the same door: the header's close circle
// and the footer's cancel pill.
const exits = screen.getAllByRole('button', { name: 'Cancel' })
expect(exits).toHaveLength(2)
exits.forEach(button => fireEvent.click(button))
expect(onCancel).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ACCEPT-022: the name stays editable on the wishlist rail, and a correction travels', () => {
// The clock, the date and the notes are all hidden for a wish, which is
// exactly why the one field that survives is worth naming: the detector's
// guess at a place name is what a tick-off is filed under, and "hide
// everything that is not the wish" is the tidy-up that would take it away.
const { onConfirm } = open({
target: 'bucket_list',
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
})
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Museum Ludwig' } })
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'bucket_list',
bucketListItemId: 88,
name: 'Museum Ludwig',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-023: a new stay arrives with no day chosen, whatever the last one had', () => {
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 2')
// Same trip, so the day list does not change and a stale `dayId` would stay
// a legal value, which is the dangerous version of this bug: the second
// stay is filed silently on the day somebody picked for the first.
update({ suggestion: stay({ id: 3, name: 'Kölner Dom', tripId: 31 }) })
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-024: switching rails keeps what was typed and leaks no trip into the journal body', () => {
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Cake, then the cathedral.' } })
pick('Not on a day yet', 'Day 1')
// Backing out of "add as a place" and choosing "write a journal entry" on
// the same row is one instance and one stay, so nothing is re-seeded.
update({ target: 'journal', journals: JOURNALS })
expect(screen.getByLabelText('Your story')).toHaveValue('Cake, then the cathedral.')
// The journal was never chosen, because the seeding runs off the stay and
// the stay has not changed. Offering a confirm here would send a body with
// no journalId in it.
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
pick('Choose a journal', 'Rhine notebook')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
// Trip 31 and day 401 are still held in state. A journal entry belongs to a
// journey, and the server reads the body at face value.
expect(onConfirm).toHaveBeenCalledWith({
target: 'journal',
journalId: 71,
notes: 'Cake, then the cathedral.',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-025: the place rail offers no date, because the day picker is where a place lands', () => {
open({ trips: TRIPS, daysForTrip })
// A place has no date of its own: the server reads a date for a journal
// entry and for nothing else, so a date field on this rail was one whose
// change moved the place nowhere while the toast still said it landed.
// The clock stays, the server does read that.
expect(screen.queryByText('Date')).toBeNull()
expect(screen.queryByRole('button', { name: 'Enter date manually' })).toBeNull()
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-026: a corrected date travels with a journal entry, and never with a place', () => {
// Both rails on one stay, so the dialog holds the corrected date in state
// across the switch: the same shape as switching rails with a note typed.
const { onConfirm, update } = open({ target: 'journal', journals: JOURNALS, trips: TRIPS, daysForTrip })
expect(screen.getByText('Date')).toBeInTheDocument()
typeDate('2026-09-12')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenLastCalledWith({
target: 'journal',
journalId: 71,
date: '2026-09-12',
time: '10:15',
endTime: '12:40',
})
// Same stay, now as a place. The corrected date is still in state and would
// be silently dropped by the server; it must not be in the body at all,
// so what the dialog claims to send is what is actually written.
update({ target: 'place' })
expect(screen.queryByText('Date')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
})
@@ -0,0 +1,402 @@
import React, { useMemo, useState } from 'react'
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichBadge from './DawarichBadge'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import CustomSelect from '../shared/CustomSelect'
export interface AcceptTargetOption {
id: number
label: string
/** Secondary half of the label — a day's date beside its number. */
badge?: string
}
export interface DawarichAcceptDialogProps {
suggestion: DawarichSuggestion | null
target: DawarichSuggestionTarget
/** Trips the user may write to, for the `place` target. */
trips?: AcceptTargetOption[]
/** Days of the chosen trip, resolved by the caller. */
daysForTrip?: (tripId: number) => AcceptTargetOption[]
/** Journeys the user may edit, for the `journal` target. */
journals?: AcceptTargetOption[]
busy?: boolean
onCancel: () => void
onConfirm: (body: DawarichAccept) => void
}
/**
* The review step: see what was recorded, correct it, then decide where it goes.
*
* The whole reason the integration is not a one-click import. A visit detector
* is right most of the time and confidently wrong the rest, and the difference
* between the two is a judgement only the traveller can make — so the values
* are prefilled from the recording and every one of them is editable before
* anything is written.
*
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
* a correction made here would be lost the next time the detector ran.
*
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
* journal rails open the first two; `bucket_list` is reached through MCP, where
* an assistant can accept a stay against a wish. The branch is kept for that and
* for the day an Atlas-side entry point wants it — the server has always been
* able to do it, and a dialog that could only handle two of three targets would
* be the thing that had to be fixed then.
*/
export default function DawarichAcceptDialog({
suggestion,
target,
trips = [],
daysForTrip,
journals = [],
busy = false,
onCancel,
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const phone = useIsPhone()
const [name, setName] = useState('')
const [notes, setNotes] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [endTime, setEndTime] = useState('')
const [tripId, setTripId] = useState<number | ''>('')
const [dayId, setDayId] = useState<number | ''>('')
const [journalId, setJournalId] = useState<number | ''>('')
// Re-seeded whenever a different suggestion opens the dialog, so the fields
// always describe the row the user just pressed rather than the previous one.
const [seededFor, setSeededFor] = useState<number | null>(null)
if (suggestion && seededFor !== suggestion.id) {
setSeededFor(suggestion.id)
setName(suggestion.name)
setNotes('')
setDate(suggestion.localDate)
setTime(clockOf(suggestion.startedAt))
setEndTime(clockOf(suggestion.endedAt))
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
setDayId('')
setJournalId(journals[0]?.id ?? '')
}
const days = useMemo(
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
[tripId, daysForTrip],
)
if (!suggestion) return null
const missingTarget =
(target === 'place' && typeof tripId !== 'number') ||
(target === 'journal' && typeof journalId !== 'number') ||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
const confirm = () => {
const body: DawarichAccept = { target }
if (target === 'place') {
if (typeof tripId === 'number') body.tripId = tripId
if (typeof dayId === 'number') body.dayId = dayId
}
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
body.bucketListItemId = suggestion.matchedBucketListItemId
}
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
if (notes.trim()) body.notes = notes.trim()
// Only an entry carries a date. A place lands on the day chosen below and
// the server reads nothing else for it, so a date corrected on the journal
// rail must not ride along when the same stay is then added as a place.
if (target === 'journal' && date && date !== suggestion.localDate) body.date = date
if (time) body.time = time
if (endTime) body.endTime = endTime
onConfirm(body)
}
// The same fields in both shells — only the frame around them differs.
const fields = (
<div className="space-y-4">
{/* What was recorded, before anything the user changed. Kept visible so a
corrected field can be compared against the source rather than
replacing it invisibly. */}
{/* What Dawarich recorded, as facts rather than as a sentence with
separators in it: a line for when, and a row of the same badges the
review list uses so the dialog and the list read as one thing. */}
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
<DawarichIcon size={20} />
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<DawarichBadge icon={Hourglass} size="sm">
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
</DawarichBadge>
{/* The source's own verdict, shown here rather than on every row of
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
of a visit, so it says nothing in a list and something here, where
somebody is deciding whether to trust this one. */}
{suggestion.sourceStatus === 'suggested' && (
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
)}
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
<DawarichBadge
icon={Gauge}
size="sm"
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
>
{t('dawarich.confidence.' + suggestion.confidenceBand)}
</DawarichBadge>
)}
</div>
</div>
{/* Why this stay is being offered against a wish at all. The row that
led here shows the wish's name and nothing else, which leaves the
reader to guess what a star beside a café has to do with a museum —
it is the rule, and the rule is short enough to state. */}
{target === 'bucket_list' && suggestion.matchedBucketListName && (
<p className="flex items-start gap-1.5 text-caption text-content-muted">
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
<span>
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
{' — '}
{t('dawarich.bucket.rule', {
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
})}
</span>
</p>
)}
<div>
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.name')}
</label>
<input
id="dawarich-accept-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
</div>
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
itself from the OS locale and a native `type="time"` ignores the 12h/24h
setting outright (#2067) — and neither follows the colour scheme. The
date column is the wide one because a localized date is long and a
clock never is.
The date is the journal rail's alone. A place has no date of its own:
the day picker below is where it lands, and a date field beside that
picker was a second answer to the same question, one the server never
read. The two clocks then share the row the way they do on the place
form. */}
{target !== 'bucket_list' && (
<div
className={`grid grid-cols-1 gap-2 ${
target === 'journal' ? 'sm:grid-cols-[minmax(0,1fr)_124px_124px]' : 'sm:grid-cols-2'
}`}
>
{target === 'journal' && (
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.date')}
</span>
<CustomDatePicker value={date} onChange={setDate} />
</div>
)}
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.from')}
</span>
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
</div>
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.to')}
</span>
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
</div>
</div>
)}
{/* Stacked and full width, in the order the choice is actually made: the
day is what somebody is deciding, the trip is usually already right.
Side by side, both pickers were half as wide as their longest option
and the menus opened narrower than the text in them. */}
{target === 'place' && (
<div className="space-y-3">
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.day')}
</span>
{/* Unplanned is a real answer, not a missing one: half the places on
a trip sit on no day, and forcing a choice here would invent one. */}
<CustomSelect
value={dayId === '' ? NO_DAY : dayId}
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
options={[
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
// The date rides as the option's badge rather than inside its
// label: the day number is what people scan for, and the date
// is the confirmation they read second.
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
]}
style={{ width: '100%' }}
/>
</div>
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.trip')}
</span>
<CustomSelect
value={tripId}
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
placeholder={t('dawarich.accept.pickTrip')}
style={{ width: '100%' }}
/>
</div>
</div>
)}
{target === 'journal' && (
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.journal')}
</span>
<CustomSelect
value={journalId}
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
placeholder={t('dawarich.accept.pickJournal')}
style={{ width: '100%' }}
/>
</div>
)}
{target !== 'bucket_list' && (
<div>
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
</label>
<textarea
id="dawarich-accept-notes"
value={notes}
onChange={e => setNotes(e.target.value)}
rows={target === 'journal' ? 5 : 3}
placeholder={t('dawarich.accept.storyPlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
{target === 'journal' && (
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
)}
</div>
)}
</div>
)
const title = t(`dawarich.accept.title.${target}`)
// The phone shell has its own sheet, its own rounding and its own footer pill,
// and every other form on it uses them. A desktop modal scaled down would be
// the one dialog in the app that came from somewhere else.
if (phone) {
return (
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
// The mark fills the tile edge to edge, rounded like every other
// avatar-sized badge in the shell — a small logo floating in a grey
// square reads as a placeholder.
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={title}
onClose={onCancel}
closeLabel={t('common.cancel')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
<FormSheetFooter
onCancel={onCancel}
cancelLabel={t('common.cancel')}
onSubmit={confirm}
submitLabel={t(`dawarich.accept.confirm.${target}`)}
submitDisabled={busy || missingTarget}
/>
</div>
</MSheet>
)
}
return (
<Modal
isOpen
onClose={onCancel}
size="lg"
title={title}
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={confirm}
disabled={busy || missingTarget}
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
{t(`dawarich.accept.confirm.${target}`)}
</button>
</div>
}
>
{fields}
</Modal>
)
}
/**
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
* A band a later version adds is shown as nothing rather than as a raw English
* word or a missing translation key.
*/
const KNOWN_BANDS = ['high', 'medium', 'low']
/**
* "No day" as a real option value.
*
* CustomSelect uses strict equality on its values and treats the empty string as
* "nothing chosen", which would make the placeholder and the deliberate answer
* indistinguishable — and unplanned is a deliberate answer here.
*/
const NO_DAY = '__none__'
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
function clockOf(iso: string): string {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
}
@@ -0,0 +1,637 @@
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-029
/**
* The room where Dawarich's two Atlas questions get answered.
*
* Nothing in here runs on its own, and that is the whole design: the Atlas is a
* curated list that carries tombstones precisely so a country somebody removed
* stays removed, so every read and every write starts with a person pressing a
* button. What is worth pinning is therefore not "does it fetch" but everything
* arranged around the fetch.
*
* - **What is written, and dated when.** A wish is ticked off on the day it was
* reached, not on today, and wishes reached on the same day travel in one
* request. A regression here either back-dates somebody's list wrongly or
* turns one confirmation into twenty requests.
* - **The five states each half can be in.** Unasked, in flight, answered,
* answered with nothing, answered with caveats. The confirm button is
* rendered or absent rather than merely greyed out, so each state has to
* decide whether that control exists at all, and the caveats under the list
* are only said when there is something to say.
* - **Refusing to write blind.** Offline is not a server error: nothing was
* sent, and "something went wrong talking to Dawarich" for a train tunnel
* sends people hunting for a bug in their own instance.
* - **Both shells.** The phone gets an MSheet with its own footer, the desktop
* a Modal; the body between them is the same tree, so a regression that
* dropped one shell would otherwise only surface on a device.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { act, fireEvent } from '@testing-library/react'
import { setForcedOffline } from '../../sync/networkMode'
import { DawarichOfflineError } from '../../repo/dawarichRepo'
import DawarichAtlasDialog from './DawarichAtlasDialog'
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
const repo = {
bucketScan: vi.fn(),
atlasSuggestions: vi.fn(),
}
vi.mock('../../repo/dawarichRepo', () => ({
dawarichRepo: {
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
},
DawarichOfflineError: class extends Error {},
}))
const api = {
confirmBucketVisits: vi.fn(),
acceptAtlasCountries: vi.fn(),
}
vi.mock('../../api/dawarich', () => ({
dawarichApi: {
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
},
}))
// The one thing the dialog reads about the device. A hoisted flag rather than a
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
const phone = vi.hoisted(() => ({ value: false }))
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
const SCAN = {
matches: [
{
itemId: 11,
name: 'Museum Ludwig',
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
alreadyVisited: false,
},
{
itemId: 12,
name: 'Kölner Dom',
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
alreadyVisited: true,
},
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
],
skippedWithoutCoordinates: 2,
truncated: false,
fetchedAt: '2026-09-12T10:00:00Z',
}
const COUNTRIES = {
countries: [
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
],
unresolved: ['Freedonia'],
fetchedAt: '2026-09-12T10:00:00Z',
}
/**
* Three wishes reached on two days. The pair sharing a timestamp is the whole
* point of the fixture: the dialog groups by the date it is about to write, and
* a regression that lost the grouping would send one request per wish. The
* third one sits far enough away to be reported in kilometres rather than
* metres, which is the other half of the row's arithmetic.
*/
const SAME_DAY = {
matches: [
{
itemId: 21,
name: 'Van Gogh Museum',
match: { at: '2026-08-02T11:00:00Z', minutes: 95, distanceMeters: 30, points: 40 },
alreadyVisited: false,
},
{
itemId: 22,
name: 'Rijksmuseum',
match: { at: '2026-08-02T11:00:00Z', minutes: 120, distanceMeters: 55, points: 51 },
alreadyVisited: false,
},
{
itemId: 23,
name: 'Kinderdijk',
match: { at: '2026-07-30T09:00:00Z', minutes: 70, distanceMeters: 1400, points: 22 },
alreadyVisited: false,
},
],
skippedWithoutCoordinates: 0,
truncated: false,
fetchedAt: '2026-09-12T10:00:00Z',
}
/**
* A reading with nothing to apologise for: no unmatched names, one country with
* more cities than fit beside a flag and one with none at all.
*/
const MORE_COUNTRIES = {
countries: [
{
countryCode: 'FR',
sourceName: 'France',
cities: ['Paris', 'Lyon', 'Nice', 'Nantes'].map(name => ({
name,
minutes: 120,
lastSeenAt: '2026-08-02T10:00:00Z',
})),
alreadyVisited: false,
},
{ countryCode: 'BE', sourceName: 'Belgium', cities: [], alreadyVisited: false },
],
unresolved: [],
fetchedAt: '2026-09-12T10:00:00Z',
}
/** A promise this test settles by hand, to pin down what is on screen mid-flight. */
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
}
beforeEach(() => {
phone.value = false
setForcedOffline(false)
toast.success.mockReset()
toast.error.mockReset()
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
})
// Force-offline is module state, not component state: left on, it would leak
// into the next file's writes.
afterEach(() => setForcedOffline(false))
describe('DawarichAtlasDialog', () => {
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
open()
expect(repo.bucketScan).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
open()
// Hidden until a result is on screen rather than merely disabled: a greyed
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
// keeps it out of the accessibility tree too.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
expect(row.textContent).toContain('49 m away')
expect(row.textContent).toContain('2 h 25 min')
// A wish with no match at all is not a row — there is nothing to decide.
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
})
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
})
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
const onChanged = vi.fn()
open({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
// With the date the stay was actually reached on — a wish from last year
// ticked off with today's date is a wrong entry in a list people keep.
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
expect(onChanged).toHaveBeenCalled()
// Marked in place rather than rescanned — one upstream request per wish is
// not worth spending to tell the reader what they just did.
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
// A country Dawarich named but TREK could not code is said out loud, not dropped.
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
})
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-010: while the recordings are being read there are placeholders and nothing to press', async () => {
const scan = deferred<typeof SCAN>()
repo.bucketScan.mockReturnValueOnce(scan.promise)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
expect(screen.getByText('Checking…')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).toBeNull()
// Neither control exists yet: there is nothing to confirm, and "check again"
// before the first answer is a second request for the same thing.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull()
await act(async () => {
scan.resolve(SCAN)
})
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
})
it('FE-DAWARICH-ATLASDLG-011: a scan that found nothing says so in a sentence and offers no confirmation', async () => {
repo.bucketScan.mockResolvedValue({ ...SCAN, matches: [], skippedWithoutCoordinates: 0 })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() =>
expect(screen.getByText('Nothing on your wishlist turned up in your recordings.')).toBeInTheDocument(),
)
// Absent, not greyed out: a dead "Tick off 0" under an empty state only asks
// to be understood.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
// The rule stays on screen, because it is the answer to "why did nothing
// turn up": driving past a wish does not count as reaching it.
expect(screen.getByText(/A wish counts as reached within 250 m and after 20 minutes/)).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-012: the caveats under a scan are only said when there are any', async () => {
repo.bucketScan.mockResolvedValue({ ...SCAN, skippedWithoutCoordinates: 0, truncated: true })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() =>
expect(screen.getByText('Only the first entries were checked. Run it again for the rest.')).toBeInTheDocument(),
)
// Nothing was skipped this time, so nothing is said about skipping: a
// "0 entries have no coordinates" line is noise dressed as information.
expect(screen.queryByText(/have no coordinates/)).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-013: a country shows the first of its cities, one without any shows none', async () => {
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'France' })).toBeInTheDocument())
const france = screen.getByRole('checkbox', { name: 'France' })
// The badge counts all four; the line beside it names three, which is what
// fits beside a flag at the width a phone gives this dialog.
expect(france.textContent).toContain('4 cities')
expect(france.textContent).toContain('Paris, Lyon, Nice')
expect(france.textContent).not.toContain('Nantes')
expect(screen.getByRole('checkbox', { name: 'Belgium' }).textContent).not.toContain('cities')
// Everything matched, so the dialog does not mention matching at all.
expect(screen.queryByText(/could not match these to a country/)).toBeNull()
expect(screen.getByText('The last 12 months were looked at.')).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-014: a reading with nothing new in it says so rather than showing an empty list', async () => {
// Germany alone, and the Atlas already has it.
repo.atlasSuggestions.mockResolvedValue({ ...COUNTRIES, countries: [COUNTRIES.countries[0]] })
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() =>
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
)
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
repo.bucketScan.mockResolvedValue(SAME_DAY)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Rijksmuseum' })).toBeInTheDocument())
// Over a kilometre out is said in kilometres: "1400 m away" is a
// measurement, "1.4 km away" is how far it was.
expect(screen.getByRole('checkbox', { name: 'Kinderdijk' }).textContent).toContain('1.4 km away')
fireEvent.click(screen.getByRole('button', { name: /Tick off 3/ }))
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(2))
// Grouped by date, not ordered by it: the row order inside a day is the
// sort's business and nothing here depends on it.
const calls = api.confirmBucketVisits.mock.calls as Array<[number[], string]>
const byDate = new Map(calls.map(([ids, at]) => [at, [...ids].sort((a, b) => a - b)]))
expect(byDate.get('2026-08-02T11:00:00Z')).toEqual([21, 22])
expect(byDate.get('2026-07-30T09:00:00Z')).toEqual([23])
// Both answers counted, not just the last one to come back.
expect(toast.success).toHaveBeenCalledWith('2 wishes ticked off')
})
it('FE-DAWARICH-ATLASDLG-016: an unticked wish is left out of the write entirely', async () => {
repo.bucketScan.mockResolvedValue(SAME_DAY)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Kinderdijk' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Kinderdijk' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Rijksmuseum' }))
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
// One request, for one day, carrying one id. The day whose other wish was
// dropped does not travel half-empty, and the other day does not travel at all.
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(1))
expect(api.confirmBucketVisits).toHaveBeenCalledWith([21], '2026-08-02T11:00:00Z')
})
it('FE-DAWARICH-ATLASDLG-017: a row that was unticked can be put back', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'false')
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
})
it('FE-DAWARICH-ATLASDLG-018: offline, a wish confirmation is refused before anything is sent', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
setForcedOffline(true)
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
// Not attempted at all: a confirmation fired into a dead connection hangs to
// a timeout and then reports a server error for something no server saw.
expect(api.confirmBucketVisits).not.toHaveBeenCalled()
// And the selection survives, so pressing it again once there is signal is enough.
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
})
it('FE-DAWARICH-ATLASDLG-019: offline, a country confirmation is refused before anything is sent', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
setForcedOffline(true)
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-020: a write that failed leaves every row exactly as it was', async () => {
// A rejection with no `{ error }` envelope at all: a socket that died
// mid-request rather than a server that answered.
api.confirmBucketVisits.mockRejectedValue(new Error('socket hang up'))
const onChanged = vi.fn()
open({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
// Nothing was marked off locally, so a second attempt still has something to
// send, and the Atlas is not asked to re-read for a write that never landed.
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeEnabled()
expect(onChanged).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-021: a refusal carrying no readable reason still gets a sentence', async () => {
// An `{ error }` that is not a string is not a message: falling through to
// the generic sentence beats printing "[object Object]" at somebody.
api.acceptAtlasCountries.mockRejectedValue({ response: { data: { error: { code: 502 } } } })
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
})
it('FE-DAWARICH-ATLASDLG-022: a read that never left the device reads as a connection, not a failure', async () => {
// The repo refuses offline reads itself, and its refusal has to survive the
// trip through failureText as the connection message rather than as
// "something went wrong talking to Dawarich", which points at the instance.
repo.atlasSuggestions.mockRejectedValue(new DawarichOfflineError())
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')),
)
// Still unasked, so the button that asks is the one on screen.
expect(screen.getByRole('button', { name: 'Look for countries' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-023: the other tab asks the other question, and each keeps its own answer', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Countries/ }))
// Switching tabs asks nothing on its own: a country reading costs an
// upstream request, and nobody asked for it by moving their eyes.
expect(repo.atlasSuggestions).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Wishlist/ }))
// The scan is still there, and was not repeated for the return trip.
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-024: "Check again" re-runs the scan and is inert while it runs', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
const second = deferred<typeof SCAN>()
repo.bucketScan.mockReturnValueOnce(second.promise)
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
// A second press while the first is in flight would run the whole scan twice:
// one upstream request per wish, for the same answer.
expect(screen.getByRole('button', { name: 'Check again' })).toBeDisabled()
await act(async () => {
second.resolve(SCAN)
})
await waitFor(() => expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled())
expect(repo.bucketScan).toHaveBeenCalledTimes(2)
})
it('FE-DAWARICH-ATLASDLG-025: "Check again" on the countries tab re-reads the countries, not the wishes', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
await waitFor(() => expect(repo.atlasSuggestions).toHaveBeenCalledTimes(2))
expect(repo.bucketScan).not.toHaveBeenCalled()
// A year back from the moment it was asked, both times: the window is the
// same question asked again, not a widening one.
const calls = repo.atlasSuggestions.mock.calls as Array<[string, string]>
for (const [from, to] of calls) {
expect(Date.parse(to) - Date.parse(from)).toBe(365 * 24 * 60 * 60 * 1000)
}
expect(Date.parse(calls[1][0])).toBeGreaterThanOrEqual(Date.parse(calls[0][0]))
})
it('FE-DAWARICH-ATLASDLG-026: on a phone it is a sheet, and the count and the confirm live in its own footer', async () => {
phone.value = true
const onClose = vi.fn()
open({ onClose })
expect(screen.getByRole('dialog', { name: 'Dawarich' })).toBeInTheDocument()
// The desktop modal puts the title in a heading: its absence is the tell
// that the right shell rendered.
expect(screen.queryByRole('heading', { name: /Dawarich/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
expect(screen.getByText('1 selected')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-027: a country that was just added stops being offered, and the Atlas is told', async () => {
const onChanged = vi.fn()
open({ initialTab: 'countries', onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
// Marked in place rather than re-read: the reading costs an upstream request
// and the only thing that changed is what the reader just pressed. The row
// leaving the list is what says the write landed.
await waitFor(() =>
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
)
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
// The Atlas keeps its own map and its own counts; without this the country
// it was just told about only appears there after a reload.
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-028: a request that died without a reason at all still gets a sentence', async () => {
// An aborted request rejects with nothing: not an Error, not a response.
// Reaching into `.response.data` on that must not be the thing that takes
// the dialog down, because a crash here loses the scan the reader is
// looking at as well as the message they needed.
repo.bucketScan.mockRejectedValue(undefined)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-029: a country can be unticked and put back, and only the ticked ones are written', async () => {
// Both offers start ticked, which is the useful default but not the answer
// every time: a border crossed on the way to somewhere else is a country
// Dawarich saw and the reader does not want in their Atlas. Taking one out
// has to actually take it out of the write, and changing one's mind has to
// put it back, because the Atlas carries tombstones and an unwanted country
// added here is removed by hand afterwards.
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Belgium' })).toBeInTheDocument())
expect(screen.getByRole('button', { name: /Add 2 countries/ })).toBeEnabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'Belgium' }))
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
// Out and back in again: the tick is a toggle, not a one-way door.
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
expect(screen.getByRole('button', { name: /Add 0 countries/ })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
// Belgium was never sent, so nothing has to be undone in the Atlas after.
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
// And it is still on offer, still unticked, for the trip it does belong to.
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
})
})
@@ -0,0 +1,578 @@
import React, { useState } from 'react'
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Skeleton } from '../shared/Skeleton'
import { SlidingTabs } from '../shared/SlidingTabs'
import Modal from '../shared/Modal'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichBadge from './DawarichBadge'
import { dawarichApi } from '../../api/dawarich'
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { formatDuration } from './dawarichSuggestionModel'
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
type Tab = 'wishes' | 'countries'
/**
* Everything Dawarich can answer about the Atlas, in a room of its own.
*
* It used to be a card wedged under the wishlist, which made two different
* questions — "which wishes did I actually reach?" and "which countries was I
* in?" — look like one paragraph of small print. Both are offers that need
* reading before they are accepted, and reading needs space, so they get a
* dialog with a tab each, one selection model and one confirm button.
*
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
* precisely so a country somebody removed stays removed — and a background job
* that ticked countries off would be fighting its owner.
*/
export default function DawarichAtlasDialog({
isOpen,
initialTab = 'wishes',
onClose,
onChanged,
}: {
isOpen: boolean
/** Which question was asked to open it — the panel has a button per tab. */
initialTab?: Tab
onClose: () => void
/** Called after something was written, so the Atlas can re-read itself. */
onChanged?: () => void
}): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const phone = useIsPhone()
const [tab, setTab] = useState<Tab>(initialTab)
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
const [scanning, setScanning] = useState(false)
const [picked, setPicked] = useState<Set<number>>(new Set())
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
const [loadingCountries, setLoadingCountries] = useState(false)
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
// One write at a time: the button stays on screen while the request is in
// flight, and a second press would send the same confirmation twice.
const [writing, setWriting] = useState(false)
const matches = orderedMatches(scan?.matches ?? [])
const openMatches = matches.filter(match => !match.alreadyVisited)
const offered = newCountries(countries?.countries ?? [])
const runScan = async (): Promise<void> => {
setScanning(true)
try {
const result = await dawarichRepo.bucketScan()
setScan(result)
// Everything with a match and not already ticked starts selected: the
// common case is "yes, all of those", and unticking the odd one is less
// work than ticking twenty.
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
} catch (err) {
toast.error(failureText(err, t))
} finally {
setScanning(false)
}
}
const loadCountries = async (): Promise<void> => {
setLoadingCountries(true)
try {
const { from, to } = countryWindow(new Date())
const result = await dawarichRepo.atlasSuggestions(from, to)
setCountries(result)
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
} catch (err) {
toast.error(failureText(err, t))
} finally {
setLoadingCountries(false)
}
}
const confirmWishes = async (): Promise<void> => {
if (picked.size === 0 || writing) return
// Writes are online-only: the reads are gated in the repo, and firing a
// confirmation into a dead connection would hang to a timeout and then
// report a server error for something the server never saw.
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setWriting(true)
try {
// Ticked off on the day each wish was reached, not on today. The dialog is
// holding that date for every row it is about to write, so it says it;
// grouping by date keeps that to one request per day rather than per wish.
const byDate = new Map<string, number[]>()
for (const m of matches) {
if (!picked.has(m.itemId) || !m.match) continue
const bucket = byDate.get(m.match.at)
if (bucket) bucket.push(m.itemId)
else byDate.set(m.match.at, [m.itemId])
}
const results = await Promise.all(
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
)
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
// Marked in place rather than rescanned: the scan costs one upstream
// request per wish, and the reader already knows what they just ticked.
setScan(prev =>
prev === null
? prev
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
)
setPicked(new Set())
onChanged?.()
} catch (err) {
toast.error(failureText(err, t))
} finally {
setWriting(false)
}
}
const confirmCountries = async (): Promise<void> => {
if (pickedCountries.size === 0 || writing) return
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setWriting(true)
try {
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
setCountries(prev =>
prev === null
? prev
: {
...prev,
countries: prev.countries.map(c =>
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
),
},
)
setPickedCountries(new Set())
onChanged?.()
} catch (err) {
toast.error(failureText(err, t))
} finally {
setWriting(false)
}
}
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
const busy = tab === 'wishes' ? scanning : loadingCountries
const loaded = tab === 'wishes' ? scan !== null : countries !== null
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
// Nothing was found, so there is nothing to confirm — the empty state says so
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
// be understood.
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
// The same two questions in both shells — only the frame differs.
const body = (
<div className="space-y-4">
<SlidingTabs<Tab>
tabs={[
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
]}
activeTab={tab}
onChange={setTab}
size="sm"
fullWidth
/>
{tab === 'wishes' ? (
<Section
icon={Star}
title={t('dawarich.bucket.title')}
description={t('dawarich.bucket.description')}
action={t('dawarich.bucket.scan')}
busyLabel={t('dawarich.bucket.scanning')}
busy={scanning}
loaded={scan !== null}
onRun={() => { void runScan() }}
empty={matches.length === 0}
emptyLabel={t('dawarich.bucket.noMatches')}
notes={[
scan && scan.skippedWithoutCoordinates > 0
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
: null,
scan?.truncated ? t('dawarich.bucket.truncated') : null,
t('dawarich.bucket.rule', {
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
}),
]}
>
{matches.map(match => (
<Row
key={match.itemId}
name={match.name}
checked={picked.has(match.itemId)}
disabled={match.alreadyVisited}
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
leading={
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
<Star className="w-4 h-4 text-accent-on" />
</span>
}
>
{match.alreadyVisited ? (
<DawarichBadge icon={Check} tone="success" size="sm">
{t('dawarich.bucket.alreadyVisited')}
</DawarichBadge>
) : (
<>
<DawarichBadge icon={MapPin} size="sm">
{formatDistance(match.match!.distanceMeters, t)}
</DawarichBadge>
<DawarichBadge icon={Hourglass} size="sm">
{formatDuration(match.match!.minutes, t)}
</DawarichBadge>
<DawarichBadge icon={CalendarDays} size="sm">
{new Date(match.match!.at).toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'short',
})}
</DawarichBadge>
</>
)}
</Row>
))}
</Section>
) : (
<Section
icon={Globe2}
title={t('dawarich.atlas.title')}
description={t('dawarich.atlas.description')}
action={t('dawarich.atlas.load')}
busyLabel={t('dawarich.atlas.loading')}
busy={loadingCountries}
loaded={countries !== null}
onRun={() => { void loadCountries() }}
empty={offered.length === 0}
emptyLabel={t('dawarich.atlas.empty')}
notes={[
countries && countries.unresolved.length > 0
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
: null,
countries ? t('dawarich.atlas.window') : null,
]}
>
{offered.map(country => (
<Row
key={country.countryCode}
name={countryLabel(country.countryCode, country.sourceName, locale)}
checked={pickedCountries.has(country.countryCode)}
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
leading={
// The Atlas page draws its countries with the same flags, and a
// list of country names without them looks like a spreadsheet.
<img
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
alt=""
className="h-6 w-8 flex-shrink-0 rounded object-cover"
/>
}
>
{country.cities.length > 0 && (
<>
<DawarichBadge icon={Building2} size="sm">
{country.cities.length === 1
? t('dawarich.atlas.citiesOne')
: t('dawarich.atlas.cities', { count: country.cities.length })}
</DawarichBadge>
<span className="min-w-0 truncate text-caption text-content-muted">
{cityLine(country.cities)}
</span>
</>
)}
</Row>
))}
</Section>
)}
</div>
)
// Rendered or not, rather than `hidden`: the attribute loses to the display
// utility on the class list, so the button stayed on screen greyed out.
const confirmButton = !loaded || !anything ? null : (
<button
type="button"
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
disabled={selected === 0 || writing}
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
}
>
<Check className="w-3.5 h-3.5" />
{tab === 'wishes'
? t('dawarich.bucket.confirm', { count: selected })
: t('dawarich.atlas.accept', { count: selected })}
</button>
)
const againButton = loaded ? (
<button
type="button"
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
disabled={busy}
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
}
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
{t('dawarich.again')}
</button>
) : null
// The phone shell has its own sheet, its own rounding and its own pill
// buttons, and every other panel on it uses them.
if (phone) {
return (
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={t('dawarich.title')}
subtitle={t('dawarich.atlas.dialogSubtitle')}
onClose={onClose}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
</span>
{againButton}
{confirmButton}
</div>
</div>
</MSheet>
)
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xl"
title={
<span className="flex items-center gap-2.5">
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
square badge and a hard corner beside a rounded dialog reads as a
pasted-in image. */}
<span className="flex-shrink-0 overflow-hidden rounded-lg">
<DawarichIcon size={26} />
</span>
<span className="min-w-0">
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
<span className="block truncate text-caption font-normal text-content-muted">
{t('dawarich.atlas.dialogSubtitle')}
</span>
</span>
</span>
}
footer={
<div className="flex w-full items-center justify-between gap-3">
<span className="text-caption text-content-muted">
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
</span>
<div className="flex items-center gap-2">
{againButton}
{confirmButton}
</div>
</div>
}
>
{body}
</Modal>
)
}
/**
* One half of the dialog: the pitch before anything has been asked, the wait
* while it is being asked, and the answer.
*
* Both halves behave identically, which is the point — the two questions are
* different, the shape of answering them is not.
*/
function Section({
icon: Icon,
title,
description,
action,
busyLabel,
busy,
loaded,
onRun,
empty,
emptyLabel,
notes,
children,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
action: string
busyLabel: string
busy: boolean
loaded: boolean
onRun: () => void
empty: boolean
emptyLabel: string
notes: (string | null)[]
children: React.ReactNode
}): React.ReactElement {
if (busy) {
return (
<div className="space-y-2" aria-busy>
<p className="text-caption text-content-muted">{busyLabel}</p>
{[0, 1, 2].map(i => (
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
<Skeleton width={32} height={32} radius={8} />
<div className="flex-1 space-y-1.5">
<Skeleton width="45%" height={13} radius={4} />
<Skeleton width="70%" height={11} radius={4} />
</div>
</div>
))}
</div>
)
}
if (!loaded) {
return (
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
<Icon className="w-5 h-5 text-content-secondary" />
</span>
<p className="text-body font-semibold text-content">{title}</p>
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
<button
type="button"
onClick={onRun}
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
>
<RefreshCw className="w-3.5 h-3.5" />
{action}
</button>
</div>
)
}
return (
<div className="space-y-3">
{empty ? (
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
{emptyLabel}
</div>
) : (
<div className="space-y-1.5">{children}</div>
)}
{notes.filter(Boolean).length > 0 && (
<div className="space-y-1 border-t border-edge-faint pt-2.5">
{notes.filter(Boolean).map(note => (
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
{note}
</p>
))}
</div>
)}
</div>
)
}
/**
* A selectable answer: what it is on the left, why it is being offered
* underneath, and a tick that says whether it will be applied.
*
* The whole row is the control — a 16px checkbox is a hard target on a phone,
* and there is nothing else in the row to click.
*/
function Row({
name,
checked,
disabled = false,
onToggle,
leading,
children,
}: {
name: string
checked: boolean
disabled?: boolean
onToggle: () => void
leading: React.ReactNode
children: React.ReactNode
}): React.ReactElement {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={name}
disabled={disabled}
onClick={onToggle}
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
} ${disabled ? 'opacity-60' : ''}`}
>
<span
aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
}`}
>
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
</span>
{leading}
<span className="min-w-0 flex-1">
<span className="block truncate text-body font-medium text-content">{name}</span>
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
</span>
</button>
)
}
/**
* What to tell the reader when a question could not be asked.
*
* Being offline is not an error the server reported — nothing was sent — and
* "something went wrong" for a plane journey is the kind of message that sends
* somebody looking for a bug in their Dawarich instance.
*/
function failureText(err: unknown, t: (key: string) => string): string {
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
return errorText(err) ?? t('dawarich.error.unknown')
}
/** The server's `{ error }` envelope, when there is one. */
function errorText(err: unknown): string | null {
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
return typeof data?.error === 'string' ? data.error : null
}

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