Compare commits

...

294 Commits

Author SHA1 Message Date
Maurice e79d0996ae Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile 2026-09-22 09:59:05 +02:00
Maurice 8936049a81 fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 09:56:13 +02:00
Maurice c0b691b8e5 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
Maurice fdab3bb347 Merge pull request #2437 from liketrek/fix/mcp-search-provider-test
test(mcp): search_place hands the provider option on
2026-09-21 23:05:25 +02:00
Maurice d288459449 test(mcp): search_place hands the provider option on, and refuses any but Google
The tool passes `{ googleOnly }` to the search since #2436; four assertions
still expected the four-argument call. Pinned the new argument, and the one
case the contract added: `provider: 'google'` reaches the search as
`googleOnly: true`, any other value is refused by the schema before the
search is asked.
2026-09-21 23:04:26 +02:00
Maurice e35d95da71 Merge pull request #2436 from liketrek/feat/places-google-only
feat(places): send a search to Google, once or always
2026-09-21 22:23:59 +02:00
Maurice 6b1d65b06c fix(i18n): the Google-only strings carried a doubled escape in English 2026-09-21 22:21:02 +02:00
Maurice b6fcaced2e feat(planner): the places filter is one select, not four pills
With the tracks tab the pill row had grown past what the rail can hold: four
pills sharing one line clipped a word as soon as the panel narrowed. The
filter is the app's own select now, the count riding on the trigger the way
it rode on the active pill, and every option carrying its own in the menu.
2026-09-21 22:18:51 +02:00
Maurice e12ae950df feat(places): send a search to Google, once or always
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
2026-09-21 22:14:44 +02:00
Maurice 11b1399b5c fix(pdf): list a day the way the plan lists it, without the booked night's stop
Booking a night writes the hotel onto its check-in day as a stop, and since
4.3.1 that stop heads its day. The day plan hides it and shows the booking as
the day's accommodation block, but the desktop toolbar hands the export the
store's own assignments, filter and all, so the PDF printed the hotel place
above a flight that left in the morning.

The export now drops that stop before it merges the day, as the list does. The
mobile export already received the filtered set and was right all along.

Closes #2434
2026-09-21 20:54:23 +02:00
Maurice 0644106f7a fix(collections): a marker click shows the place, every time
A marker click toggled the selection, so the second click on the marker of the
place that was open closed it again, which read as the details refusing to
open: every other click on the same marker showed nothing. A marker click now
selects and never clears; the map background and the close control do that,
and a list row still toggles as before.

Closes #2431
2026-09-21 19:35:41 +02:00
Maurice 903378f8ad fix(maps): build the day's road from the stops the day plan shows
Booking a night writes the hotel onto its check-in day as a stop, for the road
trip's driving chain. The day plan hides that stop and draws the hotel as the
day's bookends instead, but the route builder behind the map line and the
phone's travel connectors still read it from the store. It carries no time of
its own, and a timed booking is seated behind the last timed stop, so on a day
that opened with a flight the hidden stop led the day: the map drove from the
hotel to the departure airport and back to the hotel after landing.

The builder now leaves the booked night's stop out, as the list does, and the
bookend rules put the hotel where it belongs: behind the flight, on the road
from the arrival airport. A hotel the traveller placed as a stop of their own
is untouched.

Closes #2430
2026-09-21 18:09:44 +02:00
Maurice 84d3dd0c4a fix(roadtrip): a hop between two desks keeps its line and drops the pill
The hire desk beside the terminal is a 200 m leg, and the pill under it read
"0 km in 0 min" where the eye wants the next stop. Under 150 m and two
minutes a leg is a hop: the rail keeps the dashed line, the phone chain skips
the row, and neither offers other ways of driving it. A note a plugin
attached keeps the row.

Refs #2428
2026-09-21 17:16:19 +02:00
Maurice bdb031a1e9 feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
2026-09-21 17:05:42 +02:00
Maurice 0b4230ff4c fix(roadtrip): the booking a terminal opens has somewhere to open
The day panel owns the transport detail dialog, and in road trip mode the
day panel is not mounted: a terminal row, a ride pill and a map endpoint all
set the booking to show and nothing showed it. The rail brings the dialog
along now. Also drops a translation key nothing read.
2026-09-21 16:17:49 +02:00
Maurice fa35966ab8 feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals
The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.

A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.

The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.

Closes #2428
2026-09-21 16:03:21 +02:00
Maurice 4be2314856 docs(wiki): name the toolchain npm asks for on install since better-sqlite3 13 2026-09-21 11:09:38 +02:00
Maurice f43b89be13 test(journey): the editor tile for a clip with and without its poster 2026-09-21 11:08:38 +02:00
Maurice ac23773352 fix(journey): the map waits for its first view before it pans to the active entry
The journey map sets its first view on a rAF after the build, and the active
entry asks for its pan on a 50 ms timer. In a background tab, or on a slow
frame, the timer wins. Leaflet does not refuse a pan on a viewless map, it
takes it as the first view with the zoom still undefined: the tile layer aborts
its own add on that, the load event stops halfway, and every entry marker
queued behind it never reaches the map. The catch that was meant for this
never fired, because nothing threw. The next rebuild, after any save on the
page, then tore down markers that were never added and died in Leaflet's icon
removal, and the error boundary took the whole journey page with it.

Both pans go through whenReady now: at once on a map with a view, otherwise
right after the fit lands.
2026-09-21 11:02:01 +02:00
Maurice 8138c83558 fix(journey): the video player brings its own icons instead of asking cdn.plyr.io
Plyr's defaults point at its CDN twice: the icon sprite for the controls is
fetched over XHR, and on teardown the element is pointed at a blank clip there
to abort the stream. connect-src and media-src both refuse that host, so the
clip played but every button in the player was blank, and each close of the
lightbox logged a blocked request and an uncaught XHR error.

The sprite ships with the bundle now, served from this origin, which Plyr then
references directly instead of loading it. plyr's exports field hides the file,
so it goes through the same resolver alias the rtl-text plugin already needed.
The teardown source is empty: that aborts the stream just the same, and the
element is unmounted in the same tick.

Refs #2341
2026-09-21 11:02:01 +02:00
Maurice 141afe708d fix(journey): let the browser read a picked clip, so it previews and gets a poster
A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.

media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.

Closes #2341
2026-09-21 10:35:37 +02:00
Maurice 43a3a5217e fix(server): better-sqlite3 13, so the image never compiles it against Node headers
The runtime stage installed python3 and build-essential for one reason: when
better-sqlite3 12.x could not download its prebuilt binary, node-gyp built the
addon against the headers of whatever Node the base image carried that day.
Since Node 24.19 those headers give node::ObjectWrap a cleanup hook that the
runtime cannot unregister, and the first garbage-collected statement takes the
process down with "Assertion failed: (env) != nullptr". The published image
was spared only because its prebuild download had succeeded (issue #2421).

13.x is built on N-API, so it never touches that path, and it ships the binaries
for amd64 and arm64 inside the npm tarball. Nothing is compiled any more, and
the toolchain leaves the Dockerfile. The install runs with --ignore-scripts:
a lockfile-driven npm ci still synthesises `node-gyp rebuild` for the
binding.gyp it finds and ignores the package's gypfile:false (npm/cli#9837),
which without the toolchain would fail the build.

Closes #2421
2026-09-21 10:35:27 +02:00
Maurice d14d83ebaa fix(dashboard): a long trip title cannot widen the hero card
The hero's content grid had an auto column, which takes the title's
min-content width. A title that is one long word made the column, the
tools row and the boarding pass as wide as that word, with edit, clone,
archive and delete pushed off the card. The column is capped at the card
now, and the title breaks inside it and stops at two lines.
2026-09-20 23:08:41 +02:00
Maurice cce64c4030 fix(oidc): a successful SSO login writes the user.login audit row
Every other login method writes one; the SSO callback touched last_login and
login_count and wrote nothing, so an instance moving its users to SSO watched
its audit log go quiet while looking complete. The row is written in the
callback, where the provider has vouched for the user and the client IP is,
next to the role-change row, with method: oidc in the details the way the
passkey login names its method.

Closes #2417
2026-09-20 22:28:35 +02:00
Maurice bf0507ddb5 fix(planner): Google hours that share a half of the day convert whole to 24h
Google writes a range that stays in one half of the day with the meridiem on
the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon.
The 24-hour conversion only saw times carrying their own AM/PM, so the
opener stayed "1:00" beside a converted "15:00". The closing meridiem is
handed back to an opener that has none before converting; a range spelled
on both sides, the 12h display and lines without a meridiem are untouched.

Closes #2412
2026-09-20 20:28:23 +02:00
Maurice 9bd9ff4a80 style(collab): a link chip's title takes its width first, the host gets the rest 2026-09-20 20:11:10 +02:00
Maurice 8845f73eb9 style(collab): the host gives way before the title does, all of it 2026-09-20 20:09:22 +02:00
Maurice 6b09a8bc11 style(collab): the title of a link chip gives way last
Two to a row, the title and the host were shrinking together into two
ellipses. The host shrinks first now, the open glyph takes the favicon's
place on hover instead of room of its own, and the actions are a touch
smaller.
2026-09-20 20:07:39 +02:00
Maurice 67dff455da style(collab): link chips two to a row across the panel
A chip takes half the panel now, one to a row where the panel is narrower
than a container query's 420px; the title and the host give way inside it
instead of capping their width.
2026-09-20 20:04:39 +02:00
Maurice 97a2ea1748 style(collab): the chip actions keep their width
Unfolding them reflowed the row under the pointer, and a chip that grows as
you reach for it is a chip you cannot catch. They sit faint and come forward
on hover instead.
2026-09-20 19:52:40 +02:00
Maurice 8d70d875b3 style(collab): shared links as chips
One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
2026-09-20 19:50:06 +02:00
Maurice 3dfdc92b2a fix(collab): a shared link can be edited, and its glyph opens it
A link in the Collab tab could only be deleted and added again once its
title or address needed a correction; the update route has taken both all
along. The add dialog edits a link handed to it now, behind a pencil on
each row. The external-link glyph next to a row that was already a link
read as a button doing nothing; it is a link to the address itself now.

Closes #2414
2026-09-20 19:43:52 +02:00
Maurice 84b73d1a83 fix(planner): the last day scrolls into view beside the road trip switch
With the road trip addon on, the desktop panel puts the Days / Road trip
switch above the day list. The list was sized by height alone, so it ran the
switch's height past the panel's clipped edge and the last day could never
be scrolled into view. It is a flex child of the panel now and yields that
height; where nothing sits above it, the height still fills the panel.
2026-09-20 19:34:47 +02:00
Maurice eeda3188c2 Merge pull request #2411 from liketrek/fix/roadtrip-night-reseat-migration
fix(roadtrip): seat the nights booked before this release where their check-in says
2026-09-20 19:28:33 +02:00
Maurice be9a38e3b0 fix(accommodations): a second night on the day counts by its check-in
Two bookings on one day used to settle by whichever was seated last, because
the other night's stop carries no hour of its own. Its check-in counts now,
in the service and in the migration alike.
2026-09-20 18:48:26 +02:00
Maurice 417d0445b9 fix(roadtrip): seat the nights booked before this release where their check-in says
A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.

A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
2026-09-20 18:45:37 +02:00
Maurice a86e4b560a test(accommodations): a quiet edit still corrects a night the clocks contradict
Covers seatedByCheckIn with a check-in set, which the re-seat on a new
check-in no longer passes through, so the accommodations function ratchet
held.
2026-09-20 17:49:54 +02:00
Maurice cd769d9993 fix(roadtrip): a booked night holds its check-in and heads its day
A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.

The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.

A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.

The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
2026-09-20 17:26:50 +02:00
Maurice 979a9283cd Merge pull request #2408 from liketrek/fix/431-collection
fix(notices): keep the promise box whole when the release notice has to scroll
2026-09-20 16:56:10 +02:00
Maurice 32f2488774 fix(notices): keep the promise box whole when the note has to scroll
On a 1080p screen at 100% the release notice's right column runs out of
height. The column scrolls, but the promise box is a flex item with
overflow hidden, so it may shrink to nothing and was pressed down to its
label while the paragraphs around it kept their height. Everything inside
the two scrolling columns now keeps its size.

Below 1080px the halves stack and the panel scrolls; the single minmax row
that keeps the two-column layout inside the panel starved the release half
to zero height there. The rows go back to auto, and the ask sticks to the
bottom edge once the note is in view.
2026-09-20 16:48:31 +02:00
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
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
1468 changed files with 136860 additions and 10534 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.
+29 -12
View File
@@ -20,13 +20,15 @@ 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/
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
@@ -51,17 +53,27 @@ COPY server/package.json ./server/
# changeset, so it costs nothing. Everything copied after this point carries
# --chown=node:node for the same reason — a recursive chown in a later layer
# would copy up every inode it touches and duplicate the whole tree in the image.
#
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
# python3 + build-essential fallback compiled it against whatever Node headers
# the base image had that day, and 24.19+ headers give an addon that aborts with
# "Assertion failed: (env) != nullptr" the first time a statement is collected
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
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 +128,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"]
+1
View File
@@ -49,6 +49,7 @@ See `values.yaml` for more options.
- 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.2.0
version: 4.3.0
description: Minimal Helm chart for TREK app
appVersion: "4.2.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 }}
+12
View File
@@ -75,6 +75,18 @@ 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
+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 }}
+35 -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
+9 -7
View File
@@ -1,11 +1,11 @@
{
"name": "@trek/client",
"version": "4.2.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",
@@ -49,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",
@@ -79,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",
@@ -94,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

+14
View File
@@ -23,3 +23,17 @@ export const rtlTextAlias = {
'../dist/mapbox-gl-rtl-text.js',
),
};
/**
* Same shape for Plyr's icon sprite. The package publishes its JS and CSS entries
* and nothing else, so `plyr/dist/plyr.svg` is refused by the resolver although
* the file is right there. The player has to point at a copy on this origin
* (#2341): its default is the sprite on cdn.plyr.io, which the CSP refuses.
*/
export const plyrSpriteAlias = {
find: /^plyr\/dist\/plyr\.svg/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve('plyr')),
'plyr.svg',
),
};
+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))
}
+12 -10
View File
@@ -24,6 +24,7 @@ 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, setPlaceShadowEnabled } = 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; place_shadow_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)
@@ -318,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// 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) {
// wire and is written to this device's storage, so only a value made of
// the characters a tag may contain is taken, as the match itself. 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 releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
if (releaseTag) {
const version = releaseTag[0]
const storedVersion = localStorage.getItem('trek_app_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
@@ -364,6 +365,7 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useRoadtripPreferencesSync()
useEffect(() => {
if (isAuthenticated) {
+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)
}
+51 -1
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 }
@@ -665,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', () => {
@@ -895,4 +902,47 @@ describe('client > multipart uploads', () => {
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)
})
})
+85 -7
View File
@@ -21,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,
@@ -342,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: {
@@ -667,6 +671,8 @@ export const adminApi = {
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),
getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data),
updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { 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),
@@ -999,6 +1005,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),
@@ -1018,6 +1027,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),
@@ -1040,7 +1052,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),
@@ -1202,13 +1217,18 @@ export const mapsApi = {
* 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 }) =>
/**
* `provider: 'google'` sends this one search to Google alone, the "search Google
* instead" link under a list the index answered with the wrong place. The
* server ignores it on an instance without a Google key.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
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')),
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias, ...(provider ? { provider } : {}) }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
pluginSearchPlaces(query, lang, locationBias),
])
if (extra.length === 0) return core
@@ -1327,8 +1347,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),
@@ -1348,6 +1368,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),
@@ -1424,7 +1502,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 = {
+20 -1
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
@@ -342,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 })
})
})
+22
View File
@@ -10,6 +10,13 @@ import type {
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -53,6 +60,21 @@ 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),
// 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
+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()
}
+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 } }),
}
+9
View File
@@ -35,6 +35,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// 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',
@@ -78,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',
@@ -1,4 +1,4 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
// 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';
@@ -418,7 +418,102 @@ describe('AddonManager', () => {
expect(isOn(addonToggle('Todo List'))).toBe(true);
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
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 })]));
@@ -448,6 +543,22 @@ 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 />);
@@ -473,7 +584,7 @@ describe('AddonManager', () => {
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');
@@ -483,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();
});
@@ -545,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();
@@ -565,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());
});
+59 -6
View File
@@ -7,6 +7,9 @@ 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, 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'
@@ -15,6 +18,9 @@ import AddonSubRow from './AddonSubRow'
// used to land on the fallback.
const ICON_MAP = {
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 }) {
@@ -36,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 {
@@ -59,9 +68,20 @@ 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} />
}
@@ -79,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls
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: 'collab.links.empty' },
{ 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
@@ -121,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
if (addon.id === 'journey') await loadAddons()
// 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'))
}
@@ -129,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
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 photoProviderAddons = addons.filter(isPhotoProviderAddon)
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')
@@ -145,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
if (loading) {
return (
@@ -184,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// 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]
@@ -212,7 +265,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
<AddonTile
key={addon.id}
icon={<AddonIcon name={addon.icon} size={18} />}
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
name={label.name}
description={label.description}
enabled={addon.enabled}
@@ -232,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
@@ -308,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 },
]
/**
+3 -1
View File
@@ -44,7 +44,9 @@ export default function AddonTile({
>
<div className="flex items-start gap-3 p-4">
<div
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
// 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'
}`}
>
@@ -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">
@@ -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
@@ -43,8 +44,6 @@ type Defaults = {
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
valhalla_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -121,8 +120,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [valhallaBase, setValhallaBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -132,8 +129,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setValhallaBase(data.valhalla_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -156,8 +151,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'valhalla_base_url') setValhallaBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -387,53 +380,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
{/* The second engine, next to the first because it is the same kind of decision
and carries the same restart caveat. Only ever asked to leave a road class
out of one leg, which is the one question the OSRM above refuses. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.valhallaBase')}
<ResetButton field="valhalla_base_url" />
</label>
<input
type="text"
value={valhallaBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
placeholder="https://valhalla.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
</div>
<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 */}
@@ -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,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, splitEqualShares, 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', () => {
@@ -188,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', () => {
@@ -231,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,6 +11,7 @@
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
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
@@ -34,6 +35,67 @@ export function splitCents(amount: number, n: number): number[] {
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)
@@ -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 })
@@ -610,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')
@@ -740,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 }
@@ -749,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(
@@ -826,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 () => {
@@ -980,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()
})
@@ -1051,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 })])
@@ -1104,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
@@ -1252,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', () => {
@@ -1289,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())
@@ -1330,7 +1487,8 @@ describe('CostsPanel — expense modal', () => {
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()
+161 -13
View File
@@ -9,7 +9,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } from '../../utils/fileDownload'
@@ -18,9 +18,10 @@ import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, 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 { 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'
@@ -40,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
}
@@ -48,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,
@@ -90,6 +101,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// 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)
@@ -124,8 +138,19 @@ 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.
@@ -137,11 +162,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
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)
}
const totals = useMemo(() => {
@@ -178,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')
@@ -474,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>
@@ -694,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>
@@ -812,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>
@@ -864,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} />
@@ -875,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 && (
@@ -918,6 +955,112 @@ 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 —
@@ -1014,16 +1157,17 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
// "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)
@@ -1067,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>
)
@@ -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();
}
});
});
+3 -3
View File
@@ -48,8 +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="Remove image" 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 }}>Uploading {uploadProgress}%</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 && (
@@ -63,7 +63,7 @@ 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="Attach images" 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></>}
{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}
@@ -1,10 +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={attachment.original_name || 'Attached image'} 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="Close image" 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={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
<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>}
</>
}
@@ -0,0 +1,171 @@
// 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();
// The chip shows the host, not the whole address, and links to the address itself.
expect(screen.getByText('ferries.example')).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-009: a link can be edited in place, title and address alike', async () => {
// #2414: a link used to be delete-and-add once it needed a correction.
const user = userEvent.setup();
let put: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.put('/api/trips/1/collab/links/1', async ({ request }) => {
put = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) });
}),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /edit link|collab\.links\.edit/i }));
const title = await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i);
expect(title).toHaveValue('Ferry timetable');
await user.clear(title);
await user.type(title, 'Ferry timetable 2026');
const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i);
expect(url).toHaveValue('https://ferries.example/timetable');
await user.clear(url);
await user.type(url, 'https://ferries.example/2026');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }));
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026');
});
it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
const open = await screen.findByRole('link', { name: /ferry timetable/i });
expect(open).toHaveAttribute('href', 'https://ferries.example/timetable');
expect(open).toHaveAttribute('target', '_blank');
// The host stands beside the title; the whole address would not fit a chip.
expect(screen.getByText('ferries.example')).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();
});
});
+172 -32
View File
@@ -1,12 +1,16 @@
import { FormEvent, useEffect, useState } from 'react'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pencil, 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
@@ -14,12 +18,89 @@ interface CollabLink {
pinned: boolean | number
}
function LinkIcon({ url, title }: { url: string; title: string }) {
function LinkIcon({ url }: { url: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
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)' }} />
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
</span>
)
}
/** The part of the address worth a chip's width: the host, without a leading www. */
function hostOf(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
/**
* The add and edit 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. With a
* link handed in it edits that link (#2414): a link used to be delete-and-add
* once its title or address needed a correction.
*/
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState(link?.title ?? '')
const [url, setUrl] = useState(link?.url ?? '')
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 onSave({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// The caller 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(link ? 'collab.links.edit' : '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 }) {
@@ -28,10 +109,15 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const canEdit = useCanDo()('collab_edit', trip)
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
// 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
@@ -40,10 +126,10 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toast.error(t('common.error'))
toastRef.current.error(tRef.current('common.error'))
})
return () => { cancelled = true }
}, [tripId, toast, t])
}, [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
@@ -73,19 +159,14 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
// 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 (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
const add = async (data: { title: string; url: string }) => {
try {
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
const d = await collabApi.createLink(tripId, data)
setLinks(v => [d.link, ...v])
setTitle(''); setUrl(''); setExpanded(false)
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
} finally {
setBusy(false)
throw err
}
}
@@ -99,6 +180,17 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
}
}
const edit = async (link: CollabLink, data: { title: string; url: string }) => {
try {
const d = await collabApi.updateLink(tripId, link.id, data)
setLinks(v => v.map(x => x.id === link.id ? d.link : x))
} catch (err) {
console.error('Failed to edit collab link:', err)
toast.error(t('common.error'))
throw err
}
}
const remove = async (id: number) => {
try {
await collabApi.deleteLink(tripId, id)
@@ -108,16 +200,64 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
toast.error(t('common.error'))
}
}
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : 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={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, 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>
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: one chip per link, two to a row (see .collab-link-chip). The chip is
the link, with the host beside the title; the actions sit in its tail. */}
<div className="chat-scroll collab-link-grid" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div className="collab-link-grid__cells">
{links.map(link => (
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
</span>
</a>
{canEdit && (
<span className="collab-link-chip__actions">
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
<Pencil size={13} aria-hidden="true" />
</button>
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
</div>
)
}
@@ -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()
})
})
+65 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
@@ -7,71 +7,94 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* 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.
*
* Object URLs are held in a ref and revoked when the file they belong to goes,
* when the list is cleared and on unmount. Rebuilding every preview on each
* change, which is what both copies did, leaked one URL per image per keystroke
* of picking, and did it from inside a setState updater, so React ran it twice
* in development and leaked twice as fast.
* 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. */
add: (incoming: FileList | File[]) => boolean
/**
* 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 [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const urls = useRef<string[]>([])
const [items, setItems] = useState<PickedImage[]>([])
const itemsRef = useRef<PickedImage[]>([])
const revokeAll = useCallback(() => {
urls.current.forEach(URL.revokeObjectURL)
urls.current = []
const commit = useCallback((next: PickedImage[]) => {
itemsRef.current = next
setItems(next)
}, [])
useEffect(() => revokeAll, [revokeAll])
// 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[]) => {
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)
if (valid.length) {
setFiles(prev => {
const room = MAX_CHAT_IMAGES - prev.length
const added = valid.slice(0, Math.max(0, room))
// Only the new files get a URL; the ones already in the list keep theirs.
const fresh = added.map(URL.createObjectURL)
urls.current = [...urls.current, ...fresh]
setPreviews(p => [...p, ...fresh])
return [...prev, ...added]
})
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) }))])
}
return valid.length === all.length
}, [])
// 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) => {
setFiles(prev => prev.filter((_, i) => i !== index))
setPreviews(prev => {
const gone = prev[index]
if (gone) {
URL.revokeObjectURL(gone)
urls.current = urls.current.filter(u => u !== gone)
}
return prev.filter((_, i) => i !== index)
})
}, [])
const gone = itemsRef.current[index]
if (!gone) return
URL.revokeObjectURL(gone.url)
commit(itemsRef.current.filter((_, i) => i !== index))
}, [commit])
const clear = useCallback(() => {
revokeAll()
setFiles([])
setPreviews([])
}, [revokeAll])
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 }
}
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useChatImages } from './useChatImages'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -122,7 +122,10 @@ export function useCollabChat(tripId: any, currentUser: any) {
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
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])
@@ -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"
@@ -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
@@ -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
}
@@ -0,0 +1,174 @@
// FE-DAWARICH-ATLASPANEL-001 to FE-DAWARICH-ATLASPANEL-007
/**
* Dawarich's own panel on the Atlas, beside the stats.
*
* The panel itself holds almost no logic, and the little it does hold is the
* kind that is invisible until it is wrong:
*
* - **It has to disappear completely when the addon is off.** Not empty, not a
* blank pane of glass in the row beside the stats: absent, so an Atlas
* without Dawarich looks exactly as it did before the integration landed.
* - **It has to ask the question that was pressed.** Two tiles, one dialog, one
* `initialTab` between them. Wiring both tiles to the same tab is a bug that
* looks like a working panel.
* - **It mounts the dialog on open and throws it away on close**, deliberately,
* so a second visit starts from a fresh question rather than from a scan
* somebody ran an hour ago.
* - **It is made of the same glass as the Atlas panel next to it.** The tint
* and the cursor glare are hand-written rgba rather than tokens (a solid
* surface token would punch an opaque hole in a panel floating over a blurred
* map), so nothing but a test stops the two panels drifting apart in the
* dark theme.
*
* The dialog is pinned by its own file and stands in for itself here, so the
* panel's decisions are observable without a scan and without mocking Dawarich.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent } from '@testing-library/react'
import { render, screen } from '../../../tests/helpers/render'
import { useAddonStore } from '../../store/addonStore'
import DawarichAtlasSidePanel from './DawarichAtlasSidePanel'
vi.mock('./DawarichAtlasDialog', () => ({
default: ({
initialTab,
onClose,
onChanged,
}: {
initialTab: string
onClose: () => void
onChanged?: () => void
}) => (
<div data-testid="atlas-dialog">
<span>asked: {initialTab}</span>
<button type="button" onClick={onClose}>
dismiss
</button>
<button type="button" onClick={() => onChanged?.()}>
wrote something
</button>
</div>
),
}))
const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true }
/** The glass the Atlas hands down, reduced to the one property jsdom can answer for. */
const GLASS: React.CSSProperties = { borderRadius: 20 }
/** Inline rgba comes back from jsdom respaced; nothing here cares about the spaces. */
const rgba = (el: HTMLElement): string => el.style.background.replace(/\s/g, '')
function mount(props: Partial<React.ComponentProps<typeof DawarichAtlasSidePanel>> = {}) {
return render(<DawarichAtlasSidePanel style={GLASS} dark {...props} />)
}
beforeEach(() => {
useAddonStore.setState({ addons: [ADDON], loaded: true })
})
afterEach(() => {
useAddonStore.setState({ addons: [], loaded: false })
})
describe('DawarichAtlasSidePanel', () => {
it('FE-DAWARICH-ATLASPANEL-001: an Atlas without the addon gets no panel at all', () => {
useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] })
const { container } = mount()
// Nothing rendered, rather than an empty pane of glass in the row: the
// panel is stretched to the height of the stats beside it, so an empty one
// would be a visible hole.
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('button', { name: 'Wishlist' })).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-002: with the addon on it offers two questions and asks neither', () => {
const { container } = mount()
expect(screen.getByText('Dawarich')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Wishlist' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Countries' })).toBeInTheDocument()
// A scan costs one upstream request per wish. Nothing is asked until asked.
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
// And it wears the glass it was handed rather than one of its own.
expect(container.firstElementChild).toHaveStyle({ borderRadius: '20px' })
})
it('FE-DAWARICH-ATLASPANEL-003: the wishlist tile opens the dialog on the wishlist, and closing puts it away', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Wishlist' }))
expect(screen.getByText('asked: wishes')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'dismiss' }))
// Unmounted, not hidden: the next visit starts from a clean question rather
// than from an answer that is an hour old.
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-004: the countries tile opens the same dialog on the other question', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
expect(screen.getByText('asked: countries')).toBeInTheDocument()
expect(screen.queryByText('asked: wishes')).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-005: what the dialog wrote is reported up to the Atlas', () => {
// The Atlas keeps its own counts and its own map. Without this the confirmed
// countries only appear after a reload.
const onChanged = vi.fn()
mount({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
fireEvent.click(screen.getByRole('button', { name: 'wrote something' }))
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASPANEL-006: the tiles are a tint of the light the panel is made of, per theme', () => {
const { rerender } = render(<DawarichAtlasSidePanel style={GLASS} dark={false} />)
const wishes = screen.getByRole('button', { name: 'Wishlist' })
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
fireEvent.mouseEnter(wishes)
expect(rgba(wishes)).toBe('rgba(0,0,0,0.09)')
fireEvent.mouseLeave(wishes)
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
rerender(<DawarichAtlasSidePanel style={GLASS} dark />)
// Inverted on the dark map, not merely darkened: the tile is light added to
// the glass, and a black tint on a dark blurred map is an invisible button.
expect(rgba(screen.getByRole('button', { name: 'Wishlist' }))).toBe('rgba(255,255,255,0.07)')
})
it('FE-DAWARICH-ATLASPANEL-007: the glass glare follows the cursor and goes out behind it', () => {
const { container } = mount()
const panel = container.firstElementChild as HTMLElement
const [glare, borderGlare] = Array.from(panel.children) as HTMLElement[]
expect(glare.style.opacity).toBe('0')
fireEvent.mouseMove(panel, { clientX: 140, clientY: 60 })
// The pool of light has to sit under the pointer, not merely exist: the
// panel's box measures as the origin here, so the cursor's own coordinates
// are what should end up in the gradient. An effect that lit up in a fixed
// spot would pass an opacity-only assertion and look broken on screen.
expect(glare.style.background).toContain('at 140px 60px')
expect(glare.style.opacity).toBe('1')
expect(borderGlare.style.opacity).toBe('1')
fireEvent.mouseLeave(panel)
expect(glare.style.opacity).toBe('0')
expect(borderGlare.style.opacity).toBe('0')
})
})
@@ -0,0 +1,133 @@
import React, { useState } from 'react'
import { Globe2, Star } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useAddonStore } from '../../store/addonStore'
import { Tooltip } from '../shared/Tooltip'
import { useGlassGlare } from '../Atlas/useGlassGlare'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichAtlasDialog from './DawarichAtlasDialog'
type Question = 'wishes' | 'countries'
/**
* Dawarich's own panel on the Atlas, beside the stats.
*
* The Atlas is a map with a single glass panel floating over it, and everything
* it offers lives in that panel. Dawarich answers two questions about the same
* map — which wishes were actually reached, and which countries the recordings
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
* squeezed under the wishlist they read as fine print nobody asked for.
*
* So it gets a panel of its own, in the same glass and stretched to the same
* height, and the two questions are two tiles built like the stat blocks beside
* them: a mark, then a small capitalised label. The lists they produce open in a
* dialog — they are offers to read and confirm, and a bar over a world map is no
* place to read anything.
*
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
* exactly as it did before.
*/
export default function DawarichAtlasSidePanel({
style,
dark,
onChanged,
}: {
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
style: React.CSSProperties
dark: boolean
/** Called after something was written, so the Atlas can re-read itself. */
onChanged?: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const addonEnabled = useAddonStore(state => state.isEnabled)
const [asked, setAsked] = useState<Question | null>(null)
const glare = useGlassGlare(dark)
if (!addonEnabled('dawarich')) return null
// theme-lint-disable — the panel floats on a blurred map: a solid surface
// token would punch an opaque hole in the glass, so the tiles are a tint of
// the same light the panel is made of.
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
<button
type="button"
onClick={() => setAsked(id)}
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
style={{
background: tile,
border: 'none',
borderRadius: 14,
cursor: 'pointer',
fontFamily: 'inherit',
gap: 6,
minWidth: 74,
padding: '10px 12px',
transition: 'background 0.15s ease, color 0.15s ease',
}}
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
onMouseLeave={e => { e.currentTarget.style.background = tile }}
>
<Icon size={17} strokeWidth={2} />
<span
className="font-bold uppercase tracking-wide whitespace-nowrap"
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
{label}
</span>
</button>
)
return (
<>
<div
ref={glare.panelRef}
onMouseMove={glare.onMouseMove}
onMouseLeave={glare.onMouseLeave}
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
style={style}
>
{/* Liquid glass glare, and the border glow that follows the cursor —
the same two layers the Atlas panel carries. */}
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
}} />
{/* The long explanation moved into the tooltip: on a panel this size it
was three lines of grey that said what the two tiles already say. */}
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
<span className="relative flex items-center justify-center gap-2.5">
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
<DawarichIcon size={30} />
</span>
<span
className="truncate font-black tracking-tight text-content"
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
>
{t('dawarich.title')}
</span>
</span>
</Tooltip>
<div className="relative flex items-stretch gap-2">
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
</div>
</div>
{/* Mounted on open so every visit starts from a clean question rather than
from a scan somebody ran an hour ago. */}
{asked !== null && (
<DawarichAtlasDialog
isOpen
initialTab={asked}
onClose={() => setAsked(null)}
onChanged={onChanged}
/>
)}
</>
)
}
@@ -0,0 +1,172 @@
// FE-DAWARICH-BADGE-001 to FE-DAWARICH-BADGE-007
/**
* The one pill the whole Dawarich surface is drawn from, pinned prop by prop.
*
* It is nine lines of JSX, which is exactly why it is worth a test: the review
* list, the accept dialog and the Atlas dialog all render it, so a change here
* lands in three places at once and none of their tests would name it as the
* culprit.
*
* What is worth holding still, roughly in the order of what breaks worst:
*
* - **Icon-only means named.** `children === undefined` is the switch that
* turns a badge into a round dot of colour, and it is the same switch that
* adds `role="img"` and the accessible name. Lose the pairing and the low
* confidence flag, the "source gone" flag and the "source changed" flag all
* become unlabelled squares that a screen reader reads as nothing at all.
* - **`children === undefined`, not `!children`.** The day header counts its
* stays with the array length, so a falsy check would turn a legitimate
* count of zero into an unlabelled dot.
* - **The tone is the meaning.** Five tones, each a pair of theme tokens; a
* warning that quietly maps to the neutral pair still reads as a fact worth
* ignoring, which is the opposite of what it is there to say.
* - **The type scales with the reader's setting.** Both sizes go through
* `--fs-scale-caption`; a raw `fontSize: 11` would look identical in review
* and ignore the text-size preference entirely.
* - **A badge with something to add gets TREK's tooltip, one without gets
* none.** `title` wins over `label` as the hover text, so a pointer user can
* be told more than the accessible name says.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import DawarichBadge from './DawarichBadge'
type BadgeProps = React.ComponentProps<typeof DawarichBadge>
// The clock goes in before anything renders, the way Tooltip's own suite does
// it: swapping timers out from under a mounted tree leaves whatever it already
// scheduled on the real clock. `shouldAdvanceTime` keeps real time moving, so
// the locale promise the render helper's provider starts still settles.
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
/**
* Stands in for a lucide icon. All the badge does with one is hand it a
* className, so that string is the only thing worth asserting about it.
*/
function StubIcon({ className }: { className?: string }) {
return <svg data-testid="badge-icon" className={className} />
}
function badge(props: BadgeProps) {
const view = render(<DawarichBadge {...props} />)
return { ...view, el: view.container.firstElementChild as HTMLElement }
}
/**
* What the badge says on hover, or null when it has nothing to add and is
* therefore not wrapped in a tooltip at all. The tooltip opens on a 250 ms
* timer, so the clock has to be moved on by hand for either answer to be true
* rather than merely early.
*/
function hoverText(el: HTMLElement): string | null {
fireEvent.mouseEnter(el)
// One window covers both steps: the 250 ms open timer, and the effect that
// measures the trigger afterwards. Until that effect lands the portal is
// `visibility: hidden`, which is invisible to a role query as well as to a
// reader, so a half-flushed tooltip would read as no tooltip at all.
act(() => { vi.advanceTimersByTime(300) })
return screen.queryByRole('tooltip')?.textContent ?? null
}
describe('DawarichBadge', () => {
it('FE-DAWARICH-BADGE-001: the default pill is neutral, xs, and says nothing on hover', () => {
const { el } = badge({ children: '3 stays' })
expect(el).toHaveTextContent('3 stays')
expect(el).toHaveClass('h-[18px]', 'px-1.5', 'bg-surface-secondary', 'text-content-secondary')
// A pill that already reads "3 stays" needs no image role and no name of
// its own: both would make a screen reader announce the same thing twice.
expect(el).not.toHaveAttribute('role')
expect(el).not.toHaveAttribute('aria-label')
expect(el.style.fontSize).toBe('calc(10px * var(--fs-scale-caption, 1))')
expect(el.querySelector('[data-testid="badge-icon"]')).toBeNull()
// No title and no label, so there is nothing a tooltip could add.
expect(hoverText(el)).toBeNull()
})
it('FE-DAWARICH-BADGE-002: sm raises the box, the icon and the type together', () => {
const { el } = badge({ icon: StubIcon, size: 'sm', children: '2 h 25 min' })
expect(el).toHaveClass('h-[20px]', 'px-1.5')
expect(el.style.fontSize).toBe('calc(11px * var(--fs-scale-caption, 1))')
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-3 h-3 flex-shrink-0')
// The text truncates and the glyph does not: a long name must run out of
// room before it squeezes the icon out of shape.
expect(el.querySelector('.truncate')).toHaveTextContent('2 h 25 min')
})
it('FE-DAWARICH-BADGE-003: an icon-only pill is square, named, and offers its name on hover', () => {
const { el } = badge({ icon: StubIcon, tone: 'warning', label: 'Low confidence' })
expect(el).toHaveAttribute('role', 'img')
expect(el).toHaveAttribute('aria-label', 'Low confidence')
expect(el).toHaveClass('w-[18px]', 'justify-center', 'bg-warning-soft', 'text-warning')
// Side padding would make the circle an oval, and there is no text to pad
// away from in the first place.
expect(el).not.toHaveClass('px-1.5')
expect(el.querySelector('.truncate')).toBeNull()
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-2.5 h-2.5 flex-shrink-0')
// No title, so the accessible name doubles as the hover text and a pointer
// user is told exactly what a screen reader is told.
expect(hoverText(el)).toBe('Low confidence')
})
it('FE-DAWARICH-BADGE-004: the square grows with the size, so the dot stays a circle', () => {
const { el } = badge({ icon: StubIcon, size: 'sm', label: 'Source gone' })
expect(el).toHaveClass('h-[20px]', 'w-[20px]', 'justify-center')
expect(el).not.toHaveClass('w-[18px]')
})
it('FE-DAWARICH-BADGE-005: title is the hover text, label stays the name', () => {
const { el } = badge({
icon: StubIcon,
tone: 'warning',
label: 'Low confidence',
title: 'Dawarich is not sure this stay happened',
})
// The two are not interchangeable: the name is what the badge is, the title
// is what it has to add, and a screen reader would drown in the long one.
expect(el).toHaveAttribute('aria-label', 'Low confidence')
expect(hoverText(el)).toBe('Dawarich is not sure this stay happened')
})
it('FE-DAWARICH-BADGE-006: every tone keeps its own pair of theme tokens', () => {
const tones: Array<[NonNullable<BadgeProps['tone']>, string[]]> = [
['neutral', ['bg-surface-secondary', 'text-content-secondary']],
['quiet', ['bg-surface-secondary', 'text-content-faint']],
['accent', ['bg-accent-subtle', 'text-accent-on']],
['success', ['bg-success-soft', 'text-success']],
['warning', ['bg-warning-soft', 'text-warning']],
]
for (const [tone, classes] of tones) {
const { el, unmount } = badge({ tone, children: tone })
// Tokens rather than colour literals, or the badge stops following the
// user's scheme the moment they pick one.
expect(el).toHaveClass(...classes)
unmount()
}
})
it('FE-DAWARICH-BADGE-007: a zero is a child, not an absent one', () => {
// The day header counts its stays with the array length. Icon-only is
// `children === undefined` and not a falsy check for exactly this reason: a
// count of 0 that turned into an unlabelled dot would be a day that lost
// its number.
const { el } = badge({ tone: 'quiet', children: 0 })
expect(el).toHaveTextContent('0')
expect(el).not.toHaveAttribute('role')
expect(el).toHaveClass('px-1.5')
})
})
@@ -0,0 +1,71 @@
import React from 'react'
import { Tooltip } from '../shared/Tooltip'
/**
* One badge shape for everything Dawarich shows.
*
* Fixed height and a single radius, so a row of them lines up however many there
* are; the tone is the only thing that varies, and it carries the meaning rather
* than decorating it. `quiet` is for a fact worth stating and not worth looking
* at twice.
*
* It lives in its own file because the review list, the accept dialog and the
* Atlas dialog all use it, and three copies of a pill is how two of them end up
* a pixel apart.
*/
export default function DawarichBadge({
icon: Icon,
tone = 'neutral',
size = 'xs',
title,
label,
children,
}: {
icon?: React.ComponentType<{ className?: string }>
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
/**
* `xs` matches the places rail, where the panel sits beside rows of that size;
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
*/
size?: 'xs' | 'sm'
/** Hover text. Defaults to `label` when the badge is icon-only. */
title?: string
/**
* The accessible name for an icon-only badge. Passing it instead of children
* is what makes a flag a round dot of colour without turning a screen reader
* into someone staring at an unlabelled icon.
*/
label?: string
children?: React.ReactNode
}): React.ReactElement {
const tones: Record<string, string> = {
neutral: 'bg-surface-secondary text-content-secondary',
quiet: 'bg-surface-secondary text-content-faint',
accent: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
}
const metrics =
size === 'sm'
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
const iconOnly = children === undefined
const hint = title ?? label
const badge = (
<span
aria-label={iconOnly ? label : undefined}
role={iconOnly ? 'img' : undefined}
style={metrics.style}
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
} ${tones[tone]}`}
>
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
{!iconOnly && <span className="truncate">{children}</span>}
</span>
)
// TREK's own tooltip rather than the browser's: the native one ignores the
// colour scheme, waits a second and a half, and cannot be read on a touch
// device at all. A badge with nothing to add gets none.
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
}
@@ -0,0 +1,640 @@
// FE-DAWARICH-PANEL-001 to FE-DAWARICH-PANEL-033
/**
* The review panel both shells share, pinned end to end.
*
* `useDawarichSuggestions` is mocked rather than the network: the hook is
* already covered on its own, and driving the panel through six `status`
* values, a busy row and a refused accept via MSW would mean six round trips to
* assert one branch each. What is NOT mocked is anything the panel renders
* itself, `DawarichAcceptDialog` included, because the wiring between a row's
* button and that dialog's confirm is exactly the part that would rot silently.
*
* What is worth pinning here, roughly in the order of what breaks worst:
*
* - **The panel renders nothing when there is nothing to review.** A connected,
* quiet integration is meant to be invisible; the moment that early return
* goes, every trip in the app grows a permanent empty card explaining that
* Dawarich found nothing.
* - **`accept` is the only path that writes.** The dialog closes and the host
* is notified only when the hook says the write landed, so a refused accept
* has to leave the dialog standing with the user's corrections still in it.
* - **The two accept buttons are conditional on there being somewhere to put
* the stay.** The planner has no journey list and the journal has no trip
* list; offering the missing one opens an empty picker.
* - **A shrinking place count means somebody deleted what an acceptance
* created**, which puts the stay back into review on the server. If that
* effect stops firing, the row stays hidden until a full reload.
* - **The phone shell paints from `--m-*`.** Both shells run this one
* component, so every palette fork inside it is real behaviour that no
* mobile-screen test covers.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act, within } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import type { DawarichSuggestion } from '@trek/shared'
import type { Place } from '../../types'
import type { DawarichSuggestionsState } from '../../hooks/useDawarichSuggestions'
import { useTripStore } from '../../store/tripStore'
import DawarichSuggestionsPanel from './DawarichSuggestionsPanel'
// The hook is the panel's whole world: status, rows, the busy row and the three
// mutations. Held in a `let` the factory reads lazily, so each test hands the
// panel a different world without re-importing the component.
let S: DawarichSuggestionsState
const seenFilter = vi.fn()
vi.mock('../../hooks/useDawarichSuggestions', () => ({
useDawarichSuggestions: (filter?: { tripId?: number }) => {
seenFilter(filter)
return S
},
}))
// Both the panel and the accept dialog read this module, and the relative
// specifier resolves to the same file from either, so one mock flips the whole
// tree between the desktop tokens and the phone's own palette.
let phone = false
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone }))
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
return {
id: over.id,
sourceVisitId: String(over.id),
tripId: over.tripId ?? null,
tripTitle: over.tripTitle ?? null,
name: over.name ?? 'Cafe Reichard',
lat: over.lat ?? 50.94,
lng: over.lng ?? 6.96,
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
durationMinutes: over.durationMinutes ?? 145,
localDate: over.localDate,
sourceStatus: over.sourceStatus ?? 'suggested',
confidence: over.confidence ?? null,
confidenceBand: over.confidenceBand ?? null,
state: over.state ?? 'new',
target: over.target ?? null,
acceptedPlaceId: over.acceptedPlaceId ?? null,
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
sourceChanged: over.sourceChanged ?? false,
sourceMissing: over.sourceMissing ?? false,
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
matchedBucketListName: over.matchedBucketListName ?? null,
countryCode: over.countryCode ?? null,
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
}
}
const accept = vi.fn<(id: number, body: unknown) => Promise<boolean>>()
const dismiss = vi.fn<(id: number) => Promise<void>>()
const restore = vi.fn<(id: number) => Promise<void>>()
const reload = vi.fn()
function hookState(over: Partial<DawarichSuggestionsState> = {}): DawarichSuggestionsState {
return {
suggestions: [],
status: 'ready',
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
busyId: null,
reload,
accept,
dismiss,
restore,
...over,
}
}
/** Only the one field the panel's place-count effect reads. */
function places(count: number): Place[] {
return Array.from({ length: count }, (_, i) => ({ id: i + 1 })) as unknown as Place[]
}
const TRIPS = [{ id: 5, label: 'Cologne' }]
const JOURNALS = [{ id: 9, label: 'Rhine notes' }]
function panel(props: Partial<React.ComponentProps<typeof DawarichSuggestionsPanel>> = {}) {
return render(<DawarichSuggestionsPanel {...props} />)
}
/** The collapsible header, which is also the only element carrying aria-expanded. */
function header(): HTMLElement {
return screen.getByRole('button', { name: /From Dawarich/ })
}
/** The row card holding a given stay name, for scoping a query to one row. */
function rowOf(container: HTMLElement, name: string): HTMLElement {
const row = within(container).getByText(name).closest('.items-stretch')
if (!row) throw new Error(`no row found for ${name}`)
return row as HTMLElement
}
beforeEach(() => {
phone = false
S = hookState()
seenFilter.mockReset()
accept.mockReset().mockResolvedValue(true)
dismiss.mockReset().mockResolvedValue(undefined)
restore.mockReset().mockResolvedValue(undefined)
reload.mockReset()
useTripStore.setState({ places: [] })
})
afterEach(() => {
useTripStore.setState({ places: [] })
})
describe('DawarichSuggestionsPanel: whether it shows up at all', () => {
it('FE-DAWARICH-PANEL-001: an addon nobody switched on renders nothing', () => {
S = hookState({ status: 'idle' })
const { container } = panel()
expect(container).toBeEmptyDOMElement()
})
it('FE-DAWARICH-PANEL-002: connected and quiet is invisible, not an empty card', () => {
// Read successfully, nothing pending and nothing in the history: the panel
// stays out of the rail rather than explaining that it found nothing.
S = hookState({ status: 'ready', suggestions: [] })
const { container } = panel()
expect(container).toBeEmptyDOMElement()
})
it('FE-DAWARICH-PANEL-003: it does show up while it is still reading, before it knows', () => {
S = hookState({ status: 'loading', suggestions: [] })
panel()
// Twice: once under the title, once in the opened body.
expect(screen.getAllByText('Reading Dawarich…')).toHaveLength(2)
expect(header()).toHaveAttribute('aria-expanded', 'true')
})
it('FE-DAWARICH-PANEL-004: a failed read is said in the danger colour, without the stale error under it', () => {
S = hookState({ status: 'unavailable', lastSyncError: 'Dawarich rejected the API key.' })
const { container } = panel()
const line = container.querySelector('p.text-danger')
expect(line).not.toBeNull()
expect(line).toHaveTextContent('Dawarich could not be read.')
// The sync error belongs above a list that did load. Beside "could not be
// read" it is the same news twice in two different wordings.
expect(screen.queryByText('Dawarich rejected the API key.')).toBeNull()
})
it('FE-DAWARICH-PANEL-005: not-connected and offline each get their own sentence', () => {
S = hookState({ status: 'disconnected' })
const { unmount } = panel()
expect(screen.getAllByText('Connect Dawarich in Settings to see your stays here.')).toHaveLength(2)
unmount()
S = hookState({ status: 'offline' })
panel()
expect(screen.getAllByText('The recorded route needs a connection')).toHaveLength(2)
})
it('FE-DAWARICH-PANEL-006: freshness reads as a relative time, and says so when there is none', () => {
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
lastSyncAt: new Date(Date.now() - 120_000).toISOString(),
})
const { unmount } = panel()
// "checked 2 minutes ago" answers the question; a stamp makes the reader
// do the subtraction.
expect(header()).toHaveTextContent(/checked 2 minutes ago/)
unmount()
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })], lastSyncAt: null })
panel()
expect(header()).toHaveTextContent('Not checked yet')
})
it('FE-DAWARICH-PANEL-007: the trip filter is handed to the hook', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ tripId: 5 })
expect(seenFilter).toHaveBeenCalledWith({ tripId: 5 })
})
})
describe('DawarichSuggestionsPanel: the list', () => {
it('FE-DAWARICH-PANEL-008: stays are grouped by day, newest day first, each day counted', () => {
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-10', name: 'Cafe Reichard' }),
stay({ id: 3, localDate: '2026-09-11', name: 'Koelner Dom' }),
],
})
const { container } = panel({ trips: TRIPS })
const headings = Array.from(container.querySelectorAll('.uppercase')).map(node => node.textContent)
expect(headings).toEqual(['Fri, Sep 11', 'Thu, Sep 10'])
// The badge in the header repeats what the open list adds up to.
expect(header()).toHaveTextContent('3')
// The times, which are the only thing telling two stays on one day apart.
const row = rowOf(container, 'Cafe Reichard')
expect(row).toHaveTextContent('10:15')
expect(row).toHaveTextContent('12:40')
expect(row).toHaveTextContent('2 h 25 min')
})
it('FE-DAWARICH-PANEL-009: collapsed to start, and the header is the way back in', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
panel({ initiallyCollapsed: true, trips: TRIPS })
expect(header()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Museum Ludwig')).toBeNull()
fireEvent.click(header())
expect(header()).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-010: bare drops the card and its header, and cannot be collapsed shut', () => {
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })],
lastSyncAt: null,
})
// The phone journey opens this list inside a sheet that already carries a
// title, and `initiallyCollapsed` must not fold it away in there.
const { container } = panel({ bare: true, initiallyCollapsed: true, trips: TRIPS })
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
// Read the property rather than the attribute: what matters is that the
// card is gone, not how React chooses to serialise an empty className.
expect(container.querySelector('section')?.className).toBe('')
// The freshness line survives the missing header, as a plain paragraph.
expect(screen.getByText('Not checked yet')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-011: a sync error rides above a list that did load', () => {
S = hookState({
status: 'ready',
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
lastSyncError: 'Dawarich asked TREK to slow down. Try again shortly.',
})
panel({ trips: TRIPS })
expect(screen.getByText('Dawarich asked TREK to slow down. Try again shortly.')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-012: a row carries the flags worth interrupting for, and gone beats changed', () => {
S = hookState({
suggestions: [
stay({
id: 1,
localDate: '2026-09-10',
name: 'Museum Ludwig',
confidenceBand: 'low',
// Both set on purpose: a stay that vanished upstream may well have
// changed before it went, and saying both is two warnings about one
// row where only the fatal one matters.
sourceChanged: true,
sourceMissing: true,
}),
],
})
panel({ trips: TRIPS })
expect(screen.getByRole('img', { name: 'Unsure' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Gone from Dawarich' })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: 'Changed in Dawarich' })).toBeNull()
expect(screen.getByText(/no longer exists in Dawarich/)).toBeInTheDocument()
expect(screen.queryByText(/has changed in Dawarich/)).toBeNull()
})
it('FE-DAWARICH-PANEL-013: a stay that only changed says exactly that, in full', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', sourceChanged: true })] })
panel({ trips: TRIPS })
expect(screen.getByRole('img', { name: 'Changed in Dawarich' })).toBeInTheDocument()
expect(screen.getByText(/has changed in Dawarich since you used it/)).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-014: the trip name shows only where rows can come from more than one trip', () => {
const rows = [stay({ id: 1, localDate: '2026-09-10', tripId: 5, tripTitle: 'Cologne' })]
S = hookState({ suggestions: rows })
const { unmount } = panel({ trips: TRIPS })
expect(screen.getByText('Cologne')).toBeInTheDocument()
unmount()
// Inside one trip's rail every row is from that trip, so the name is noise.
S = hookState({ suggestions: rows })
panel({ tripId: 5, trips: TRIPS })
expect(screen.queryByText('Cologne')).toBeNull()
})
it('FE-DAWARICH-PANEL-015: a stay with no trip behind it gets no empty badge', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', tripId: null, tripTitle: null })] })
const { container } = panel({ trips: TRIPS })
// Two badges on the row, the clock and the duration, and nothing else.
expect(rowOf(container, 'Cafe Reichard').querySelectorAll('.rounded-full.font-semibold')).toHaveLength(2)
})
})
describe('DawarichSuggestionsPanel: acting on a stay', () => {
it('FE-DAWARICH-PANEL-016: an action is offered only where there is somewhere to put the stay', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
const { unmount } = panel({ trips: [], journals: [] })
// No trips and no journeys: only "not a place I visited" is still an answer.
expect(screen.queryByRole('button', { name: 'Add as a place' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Write a journal entry' })).toBeNull()
expect(screen.getByRole('button', { name: 'Not a place I visited' })).toBeInTheDocument()
unmount()
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Write a journal entry' })).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-017: dismissing hands the row id straight to the hook', () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Not a place I visited' }))
expect(dismiss).toHaveBeenCalledWith(42)
})
it('FE-DAWARICH-PANEL-018: the row being written is the row that goes dead, and only that one', () => {
S = hookState({
suggestions: [
stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' }),
stay({ id: 43, localDate: '2026-09-11', name: 'Koelner Dom' }),
],
busyId: 42,
})
const { container } = panel({ trips: TRIPS })
expect(within(rowOf(container, 'Museum Ludwig')).getByRole('button', { name: 'Add as a place' })).toBeDisabled()
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Add as a place' })).toBeEnabled()
})
it('FE-DAWARICH-PANEL-019: accepting opens the review step and reports a write that landed', async () => {
const onAccepted = vi.fn()
const daysForTrip = vi.fn((tripId: number) => [{ id: 70 + tripId, label: 'Day 1', badge: 'Thu, Sep 10' }])
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
panel({ trips: TRIPS, journals: JOURNALS, daysForTrip, onAccepted })
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
// The dialog opens on the row that was pressed, for the target that was pressed.
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
expect(daysForTrip).toHaveBeenCalledWith(5)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
})
expect(accept).toHaveBeenCalledWith(42, expect.objectContaining({ target: 'place', tripId: 5 }))
// Only now: the host refreshes its own list off the back of a write that landed.
expect(onAccepted).toHaveBeenCalledWith(expect.objectContaining({ id: 42 }))
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
})
it('FE-DAWARICH-PANEL-020: a refused write leaves the dialog standing, with the correction still in it', async () => {
const onAccepted = vi.fn()
accept.mockResolvedValue(false)
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS, onAccepted })
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Corrected name' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
})
expect(accept).toHaveBeenCalledWith(
42,
expect.objectContaining({ target: 'journal', journalId: 9, name: 'Corrected name' }),
)
// Closing here would throw away what the user typed and leave them nothing
// to retry with.
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Corrected name')
expect(onAccepted).not.toHaveBeenCalled()
})
it('FE-DAWARICH-PANEL-021: a host that wants no callback still gets its dialog closed', async () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
})
expect(accept).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('heading', { name: 'Write a journal entry' })).toBeNull()
})
it('FE-DAWARICH-PANEL-022: cancelling writes nothing', () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
expect(accept).not.toHaveBeenCalled()
})
})
describe('DawarichSuggestionsPanel: what was already dealt with', () => {
const handledRows = [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', state: 'accepted', target: 'place' }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
// Accepted before `target` was recorded: the wording falls back rather than
// printing a missing translation key at somebody.
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'accepted', target: null }),
]
it('FE-DAWARICH-PANEL-023: the history stays folded away until it is asked for', () => {
S = hookState({ suggestions: handledRows })
panel({ trips: TRIPS })
expect(screen.queryByText('Museum Ludwig')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
expect(screen.getByText('Added as a place')).toBeInTheDocument()
expect(screen.getByText('Dismissed')).toBeInTheDocument()
expect(screen.getByText('In the journal')).toBeInTheDocument()
// A history row carries its own date, which the open list keeps in the day
// heading it no longer sits under.
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Hide the ones already dealt with' }))
expect(screen.queryByText('Museum Ludwig')).toBeNull()
})
it('FE-DAWARICH-PANEL-024: only a dismissal can be put back, and it goes through the hook', () => {
S = hookState({ suggestions: handledRows })
const { container } = panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
// An accepted stay already produced a place or an entry; "put back" there
// would mean silently unpicking something the user wrote.
expect(within(rowOf(container, 'Museum Ludwig')).queryByRole('button', { name: 'Put back' })).toBeNull()
// And no history row offers the accept buttons a second time.
expect(within(rowOf(container, 'Koelner Dom')).queryByRole('button', { name: 'Add as a place' })).toBeNull()
fireEvent.click(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' }))
expect(restore).toHaveBeenCalledWith(2)
})
it('FE-DAWARICH-PANEL-025: a restore in flight disables the button that started it', () => {
S = hookState({ suggestions: handledRows, busyId: 2 })
const { container } = panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })).toBeDisabled()
})
it('FE-DAWARICH-PANEL-026: nothing left open says so instead of showing an empty day', () => {
S = hookState({ suggestions: [handledRows[0]] })
const { container } = panel({ trips: TRIPS })
expect(screen.getByText('Everything recorded here has been dealt with.')).toBeInTheDocument()
// And the count badge goes with the last open row rather than showing a zero.
expect(container.querySelector('.text-accent-text')).toBeNull()
})
})
describe('DawarichSuggestionsPanel: the deleted-place hint', () => {
it('FE-DAWARICH-PANEL-027: a shrinking place count reloads, a growing one does not', () => {
useTripStore.setState({ places: places(3) })
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ tripId: 5, trips: TRIPS })
// Mounting is not a shrink.
expect(reload).not.toHaveBeenCalled()
// Deleting the place an acceptance created is how somebody says "not that
// one" after the fact, and the server puts the stay back into review.
act(() => { useTripStore.setState({ places: places(2) }) })
expect(reload).toHaveBeenCalledTimes(1)
// A place appearing is usually this panel's own doing.
act(() => { useTripStore.setState({ places: places(6) }) })
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-PANEL-028: outside a trip the place count is not this panel\'s business', () => {
useTripStore.setState({ places: places(3) })
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
// The journal shell mounts this without a trip, and whatever trip happens to
// be loaded has nothing to do with the rows it shows.
panel({ trips: TRIPS })
act(() => { useTripStore.setState({ places: places(1) }) })
expect(reload).not.toHaveBeenCalled()
})
})
describe('DawarichSuggestionsPanel: the phone shell', () => {
it('FE-DAWARICH-PANEL-029: the phone paints from its own palette, header to action pill', () => {
phone = true
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', sourceChanged: true }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
],
lastSyncAt: new Date(Date.now() - 60_000).toISOString(),
})
const { container } = panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Show 1 already dealt with' }))
// A card in the desktop surface tokens glares inside the mobile shell, and
// neither token set exists outside its own shell, so every surface here has
// to come from the `--m-*` palette.
expect(container.querySelector('section')?.className).toContain('var(--m-ic)')
expect(header().className).toContain('var(--m-inner)')
expect(container.querySelector('.bg-m-card')).not.toBeNull()
expect(container.querySelector('.bg-m-act')).not.toBeNull()
expect(container.querySelector('.text-m-ink')).not.toBeNull()
expect(container.querySelectorAll('.text-m-muted').length).toBeGreaterThan(0)
expect(container.querySelector('.text-m-faint')).not.toBeNull()
// The actions keep their accessible names, which is what makes dropping the
// hover-only tooltip on a touch device cost nothing.
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-030: bare on a phone, with nothing left open', () => {
phone = true
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10', state: 'accepted', target: 'journal' })],
lastSyncAt: null,
})
const { container } = panel({ bare: true, trips: TRIPS })
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
expect(screen.getByText('Everything recorded here has been dealt with.').className).toContain('text-m-muted')
expect(screen.getByText('Not checked yet').className).toContain('text-m-muted')
expect(container.querySelector('section')?.className).toBe('')
})
it('FE-DAWARICH-PANEL-033: folded shut on a phone, the arrow that says so comes from the phone palette', () => {
phone = true
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
// The planner mounts this one collapsed, so on a phone the right-pointing
// arrow is the header's ordinary state and not an edge case. It is also the
// only part of the header an expanded phone never paints, which is exactly
// how a desktop token would survive in there unnoticed.
const { container } = panel({ initiallyCollapsed: true, trips: TRIPS })
expect(header()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Museum Ludwig')).toBeNull()
expect(container.querySelector('.lucide-chevron-right.text-m-faint')).not.toBeNull()
expect(container.querySelector('.lucide-chevron-down')).toBeNull()
expect(container.querySelector('.text-content-faint')).toBeNull()
})
})
describe('DawarichSuggestionsPanel: the desktop tooltip', () => {
it('FE-DAWARICH-PANEL-031: a row action explains itself on focus, where there is a pointer to do it with', () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ trips: TRIPS })
fireEvent.focus(screen.getByRole('button', { name: 'Not a place I visited' }))
act(() => { vi.advanceTimersByTime(300) })
// The icon is the whole button, so without this a square is a mystery to
// anybody who has not pressed one before. Reaching it by keyboard is the
// one route where there is no hover to discover it with.
expect(screen.getByRole('tooltip')).toHaveTextContent('Not a place I visited')
} finally {
vi.useRealTimers()
}
})
})
describe('DawarichSuggestionsPanel: open and handled in the same list', () => {
it('FE-DAWARICH-PANEL-032: the two halves are counted separately and only the open half gets days', () => {
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'accepted', target: 'place' }),
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'dismissed' }),
],
})
const { container } = panel({ trips: TRIPS })
// Counting all three would send somebody looking for work that is done.
expect(container.querySelector('.bg-accent')).toHaveTextContent('1')
expect(screen.getByRole('button', { name: 'Show 2 already dealt with' })).toBeInTheDocument()
// And the day rail is built from the open half alone: a heading for a day
// whose only stay was dismissed is an empty day with a coloured dot on it.
expect(container.querySelectorAll('.uppercase')).toHaveLength(1)
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
})
})
@@ -0,0 +1,540 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useIsPhone } from '../../mobile/useIsPhone'
import { relativeTime } from '../../utils/relativeTime'
import { Tooltip } from '../shared/Tooltip'
import DawarichIcon from '../shared/DawarichIcon'
import Badge from './DawarichBadge'
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
/**
* The panel sits inside the places rail, and it has to read at the same size as
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
* line of it a size larger than the trip's own places, which is the one thing a
* panel about somebody else's data must not be.
*
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
* kept as calc() against the per-tier multipliers so the appearance text-size
* control still reaches them — a bare number would not.
*/
const TYPE = {
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
} as const
export interface DawarichSuggestionsPanelProps {
/** Only stays that fall inside this trip. */
tripId?: number
/** Trips the user may write a place into. */
trips?: AcceptTargetOption[]
daysForTrip?: (tripId: number) => AcceptTargetOption[]
/** Journeys the user may add an entry to. */
journals?: AcceptTargetOption[]
/** Called after something was written, so the host can refresh its own list. */
onAccepted?: (suggestion: DawarichSuggestion) => void
/** Start collapsed. The planner does; the journal does not. */
initiallyCollapsed?: boolean
/**
* Drop the panel's own card and header.
*
* For a sheet that already carries both — the phone's journey opens this list
* in one, and a second "From Dawarich" under the first is a stutter.
*/
bare?: boolean
}
/**
* The stays Dawarich recorded, waiting to be reviewed.
*
* One component for both shells. The phone and the desktop place it
* differently, but the rows are the same rows, and a second copy of them is a
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
* next door, and the same reason SonarCloud's duplication budget exists.
*
* **Grouped by day, and coloured like the map.** A day of travel produces four
* or five stays; repeating the full date on each of them buries the one thing
* that tells them apart, which is the time. The day's dot carries the colour its
* line is drawn in on the trip map, so "the purple day" means the same thing in
* both places.
*
* The panel renders **nothing at all** when there is nothing to review. An
* integration that is connected and quiet should be invisible, not a permanent
* empty card explaining that it found nothing.
*/
export default function DawarichSuggestionsPanel({
tripId,
trips = [],
daysForTrip,
journals = [],
onAccepted,
initiallyCollapsed = false,
bare = false,
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const S = useDawarichSuggestions({ tripId })
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
// tokens glares next to it. Same component, the shell's own colours.
const phone = useIsPhone()
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
const [showHandled, setShowHandled] = useState(false)
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
// Deleting the place an acceptance created is how somebody says "not that
// one" after the fact: the server puts the stay back into review, and a
// shrinking place count is what tells us to go and ask. Only a shrink — a
// place being added is usually this panel's own doing.
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
const lastPlaceCount = useRef(placeCount)
const reload = S.reload
useEffect(() => {
const shrank = placeCount < lastPlaceCount.current
lastPlaceCount.current = placeCount
if (shrank) reload()
}, [placeCount, reload])
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
const openDays = useMemo(() => groupByDay(open), [open])
// Nothing to review and nothing to report: stay out of the way entirely.
if (S.status === 'idle') return null
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
const confirm = async (body: DawarichAccept) => {
if (!pending) return
const ok = await S.accept(pending.suggestion.id, body)
if (ok) {
onAccepted?.(pending.suggestion)
setPending(null)
}
}
const rowProps = {
phone,
// Offered only where there is somewhere to put it: the planner has no
// journey list, and a button that opens an empty picker is worse than none.
allowJournal: journals.length > 0,
allowPlace: trips.length > 0,
// The trip name is noise when every row is from the same trip.
showTrip: tripId === undefined,
// In the trip rail the arrival is the half that places a stop; how long somebody stood
// there is a second figure on a row that already does not wrap.
showDuration: tripId === undefined,
}
return (
<section
className={
bare
? ''
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
}
>
{bare ? (
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{statusLine(S, open.length, t, locale)}
</p>
) : (
<button
type="button"
onClick={() => setCollapsed(value => !value)}
aria-expanded={!collapsed}
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
}`}
>
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
square badge and a hard corner beside a rounded card reads as a
pasted-in image. */}
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
<DawarichIcon size={22} />
</span>
<span className="flex-1 min-w-0">
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
{t('dawarich.suggestions.title')}
</span>
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{statusLine(S, open.length, t, locale)}
</span>
</span>
{open.length > 0 && (
<span
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
}`}
style={TYPE.micro}
>
{open.length}
</span>
)}
{collapsed
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
</button>
)}
{(bare || !collapsed) && (
<div className="border-t border-edge-secondary">
{S.status !== 'ready' && (
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
{statusLine(S, open.length, t, locale)}
</p>
)}
{S.lastSyncError && S.status === 'ready' && (
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
)}
{open.length === 0 && S.status === 'ready' && (
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
)}
{openDays.map(day => (
<div key={day.date}>
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
{/* The colour this day's line is drawn in on the map. */}
<span
aria-hidden="true"
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ background: day.color }}
/>
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{formatDayHeading(day.date, locale)}
</span>
<span className="flex-1 h-px bg-edge-faint" />
<Badge tone="quiet">{day.stays.length}</Badge>
</div>
{day.stays.map(suggestion => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
busy={S.busyId === suggestion.id}
{...rowProps}
onAccept={target => setPending({ suggestion, target })}
onDismiss={() => S.dismiss(suggestion.id)}
/>
))}
</div>
))}
{handled.length > 0 && (
<div className="border-t border-edge-faint">
<button
type="button"
onClick={() => setShowHandled(value => !value)}
className={`w-full px-3.5 py-2 text-left ${
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
}`}
style={TYPE.meta}
>
{showHandled
? t('dawarich.suggestions.hideHandled')
: t('dawarich.suggestions.showHandled', { count: handled.length })}
</button>
{showHandled &&
handled.map(suggestion => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
busy={S.busyId === suggestion.id}
showTrip={rowProps.showTrip}
showDate
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
/>
))}
</div>
)}
</div>
)}
<DawarichAcceptDialog
suggestion={pending?.suggestion ?? null}
target={pending?.target ?? 'journal'}
trips={trips}
daysForTrip={daysForTrip}
journals={journals}
busy={S.busyId !== null}
onCancel={() => setPending(null)}
onConfirm={confirm}
/>
</section>
)
}
/**
* One stay: a name, when it was, and how to act on it.
*
* Two lines and no chip soup. The meta line carries only what varies — how long,
* and the two things worth interrupting for: a wish this fulfils, and a
* detection the source itself was unsure about. The "detected, unconfirmed"
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
* unconfirmed is the normal state of a visit, so saying it everywhere said
* nothing. Reviewing it here *is* the confirmation.
*
* The actions fade in on hover on a pointer device and stay visible on touch,
* where there is no hover to reveal them.
*/
/**
* One stay, as a card with its actions clipped to the side.
*
* Exported because the journal draws these rows in its own timeline now — folded into a
* day rather than stacked in a panel above it — and a second copy of a row this detailed
* would be two things to keep in step, plus a straight hit on the duplication budget.
*/
export function SuggestionRow({
suggestion,
busy,
allowJournal = false,
allowPlace = false,
showTrip = false,
showDate = false,
showDuration = true,
phone = false,
onAccept,
onDismiss,
onRestore,
}: {
suggestion: DawarichSuggestion
busy: boolean
allowJournal?: boolean
allowPlace?: boolean
showTrip?: boolean
showDate?: boolean
/** How long the stay lasted. Off in the trip rail, where the arrival is the useful half. */
showDuration?: boolean
/** Rendered inside the phone shell, which has its own palette and its own reach. */
phone?: boolean
onAccept?: (target: DawarichSuggestionTarget) => void
onDismiss?: () => void
onRestore?: () => void
}): React.ReactElement {
const { t, locale } = useTranslation()
// The traveller's own clock: these times used to come straight out of the timestamp, so
// a 12-hour setting got 24-hour times here and nowhere else.
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
return (
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
{/* A stay is a card with its actions clipped to the side — the shape an
expense already has in Costs. One layout in both shells; only the
palette differs, because the phone paints from `--m-*` and the desktop
from the semantic tokens, and neither set exists outside its own. */}
<div
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
}`}
>
{/* The name gets the line, and the one badge that is about the name
itself rides with it: "Unsure" qualifies what this place is called,
not how long somebody stayed, and two lines below the name it read as
one more fact among the times. It never pushes the name out — the
name truncates, the flag does not. */}
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
<span className="truncate">{suggestion.name}</span>
{/* The mark, never the word: the name is what the line is for, and
"Unsure" beside it is a caption competing with a title. The tooltip
and the accessible name still say it in full. */}
{suggestion.confidenceBand === 'low' && (
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
)}
</p>
{/* One badge row, one badge shape, colour carrying the meaning: neutral
for a fact, accent for a wish this fulfils, success for what was
already taken, amber for the two things worth interrupting for.
Nothing wraps — a row that reflows every time a name is long reads as
a mess, so it stays one line and the badges that do not fit are cut
off rather than pushed onto a second. */}
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
never breaks, so a second line of them reads as a second line of
badges rather than as a paragraph coming apart. Cutting them off
instead would hide the wish a stay fulfils, which is the one thing in
the row worth the space. */}
<div className="mt-1 flex flex-wrap items-center gap-1">
{/* Ordered by what a reader needs first, because the rail is narrow and
the row does not wrap: how long, then what it would fulfil, then the
flags. The flags are icon-only — the word costs half the row and the
sentence under the badges says it properly anyway. */}
{showDate && (
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
)}
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt, is12h)}</Badge>
{showDuration && <Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>}
{suggestion.state === 'accepted' && (
<Badge icon={Check} tone="success">
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
</Badge>
)}
{suggestion.state === 'dismissed' && (
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
)}
{showTrip && suggestion.tripTitle && (
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
)}
{suggestion.sourceMissing && (
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
)}
{!suggestion.sourceMissing && suggestion.sourceChanged && (
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
)}
</div>
{/* The long form of the two warnings, under the badges: the badge is the
flag, this is what it means, and somebody reading the panel for the
first time should not have to hover to find out. */}
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
{suggestion.sourceMissing
? t('dawarich.suggestions.sourceMissing')
: t('dawarich.suggestions.sourceChanged')}
</p>
)}
</div>
{/* Always on screen. Hiding them until hover made the only two things a row
is for invisible to anyone who had not discovered that hovering did
something — and on a touch screen there is no hover at all.
A column beside the text in the desktop rail, which is narrow and tall;
a row on a phone, where there is width to spare and a stack of small
squares reads as a scrollbar. Centred either way, against the name and
the time it belongs to. */}
{onAccept && (
<div className={ACTIONS(phone)}>
{allowJournal && (
<RowAction
label={t('dawarich.suggestions.asJournal')}
icon={<BookOpen className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={() => onAccept('journal')}
/>
)}
{allowPlace && (
<RowAction
label={t('dawarich.suggestions.asPlace')}
icon={<MapPin className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={() => onAccept('place')}
/>
)}
{onDismiss && (
<RowAction
label={t('dawarich.suggestions.dismiss')}
icon={<X className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={onDismiss}
/>
)}
</div>
)}
{onRestore && (
<div className={ACTIONS(phone)}>
<RowAction
label={t('dawarich.suggestions.restore')}
icon={<RotateCcw className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={onRestore}
/>
</div>
)}
</div>
)
}
/**
* Where a row keeps its actions: a pill clipped to the side of the card, the
* same in both shells, painted from whichever palette the shell has.
*/
const ACTIONS = (phone: boolean): string =>
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
/**
* One action on a row: a real target with a real accessible name.
*
* A square with a border rather than a bare glyph, because a bare glyph on a
* white rail reads as decoration; this reads as a button, which is what it is.
* The label is a tooltip and the accessible name — spelling it out beside the
* icon would cost the row the width its name needs.
*
* All of them look the same. Marking one as the primary action would be TREK
* deciding for the reader which of "put it on the map" and "write about it" they
* meant, and the whole review step exists because that is theirs to decide.
*
* Hover fills the square with the inverse surface — near-black in the light
* scheme, near-white in the dark one — so the button somebody is about to press
* is unmistakable without inventing a colour the theme does not have.
*/
function RowAction({
label,
icon,
disabled,
phone = false,
onClick,
}: {
label: string
icon: React.ReactNode
disabled?: boolean
phone?: boolean
onClick: () => void
}): React.ReactElement {
const button = (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
phone
? 'text-m-muted active:scale-95'
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
}`}
>
{icon}
</button>
)
// No tooltip on a phone: there is no hover to open it with, and the label is
// already the button's accessible name.
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
}
/**
* Where the data came from and how fresh it is, in one line under the title.
*
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
* subtraction themselves — on two lines, seconds included.
*/
function statusLine(
state: ReturnType<typeof useDawarichSuggestions>,
openCount: number,
t: (key: string, params?: Record<string, unknown>) => string,
locale: string,
): string {
if (state.status === 'loading') return t('dawarich.suggestions.loading')
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
if (state.status === 'offline') return t('dawarich.trail.offline')
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
// Just the freshness. The count sits in the badge beside this line, and
// gluing the two together with a separator said the same thing twice.
return state.lastSyncAt
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
: t('dawarich.neverSynced')
}
@@ -0,0 +1,188 @@
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-014
/**
* The Atlas dialog's arithmetic, with no React around it.
*
* Everything here is small enough to look correct and wrong enough to matter if
* it is not. Three of the five carry a real trap:
*
* 1. `orderedMatches` sorts on two keys with a hand-written comparator that
* never returns 0. That is fine for ordering and a hazard for anything else,
* so the cases below check that the order is the same whichever way round
* the input arrives and that two wishes reached at the same moment both
* survive the sort rather than one of them being swapped away.
* 2. `countryLabel` runs on whatever `Intl` the device shipped. A WebView
* without `Intl.DisplayNames` throws, one with a partial region table
* answers `undefined`, and both have to end up as Dawarich's own spelling
* rather than as a crash or an empty label beside a flag.
* 3. `formatDistance` switches units at exactly a kilometre, which is the one
* input a refactor gets wrong.
*/
import { describe, it, expect } from 'vitest'
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
import {
cityLine,
countryLabel,
countryWindow,
formatDistance,
newCountries,
orderedMatches,
} from './dawarichAtlasModel'
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
return {
itemId: over.itemId,
name: over.name ?? `Wish ${over.itemId}`,
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
alreadyVisited: over.alreadyVisited ?? false,
}
}
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
return {
countryCode: over.countryCode,
sourceName: over.sourceName ?? over.countryCode,
cities: over.cities ?? [],
alreadyVisited: over.alreadyVisited ?? false,
}
}
const t = (key: string, params?: Record<string, unknown>): string =>
`${key}:${JSON.stringify(params ?? {})}`
describe('countryWindow', () => {
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
const now = new Date('2026-09-12T18:00:00Z')
const { from, to } = countryWindow(now)
expect(to).toBe('2026-09-12T18:00:00.000Z')
expect(from).toBe('2025-09-12T18:00:00.000Z')
})
})
describe('orderedMatches', () => {
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
expect(ordered.map(m => m.itemId)).toEqual([2])
})
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
const ordered = orderedMatches([
match({ itemId: 1, alreadyVisited: true }),
match({ itemId: 2 }),
])
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
})
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
const ordered = orderedMatches([
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
])
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
})
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
orderedMatches(input)
expect(input.map(m => m.itemId)).toEqual([1, 2])
})
it('FE-DAWARICH-ATLASMODEL-011: the order they arrive in does not change the order they are shown in', () => {
// Both arms of both comparisons. A comparator that answers consistently one
// way round and not the other sorts whatever the scan happened to return,
// which is a list that reshuffles itself between two identical scans.
const ticked = match({ itemId: 1, alreadyVisited: true })
const openWish = match({ itemId: 2 })
expect(orderedMatches([ticked, openWish]).map(m => m.itemId)).toEqual([2, 1])
expect(orderedMatches([openWish, ticked]).map(m => m.itemId)).toEqual([2, 1])
const older = match({ itemId: 3, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
const newer = match({ itemId: 4, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
expect(orderedMatches([older, newer]).map(m => m.itemId)).toEqual([4, 3])
expect(orderedMatches([newer, older]).map(m => m.itemId)).toEqual([4, 3])
})
it('FE-DAWARICH-ATLASMODEL-012: two wishes reached in the same moment both survive the sort', () => {
// The comparator never answers 0, so equal timestamps are reported as "swap"
// in both directions. Ordering them either way is fine; losing one is not.
const at = '2026-08-02T11:00:00Z'
const ordered = orderedMatches([
match({ itemId: 1, match: { at, minutes: 95, distanceMeters: 30, points: 40 } }),
match({ itemId: 2, match: { at, minutes: 120, distanceMeters: 55, points: 51 } }),
])
expect(ordered.map(m => m.itemId).sort((a, b) => a - b)).toEqual([1, 2])
})
})
describe('newCountries', () => {
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
const offered = newCountries([
country({ countryCode: 'DE', alreadyVisited: true }),
country({ countryCode: 'NL' }),
])
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
})
})
describe('countryLabel', () => {
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
})
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
// Not an ISO region: Intl either throws or echoes it back, and neither is a
// name a reader would recognise.
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
})
it('FE-DAWARICH-ATLASMODEL-013: a runtime whose Intl names nothing still labels the row', () => {
// The other half of the fallback, and the one no desktop browser reproduces:
// a WebView that ships Intl.DisplayNames with a partial region table answers
// `undefined` instead of throwing, and an empty string beside a flag is a
// row nobody can identify.
const real = Intl.DisplayNames
Object.defineProperty(Intl, 'DisplayNames', {
configurable: true,
writable: true,
value: class {
of(): string | undefined {
return undefined
}
},
})
try {
expect(countryLabel('NL', 'Netherlands', 'en')).toBe('Netherlands')
} finally {
Object.defineProperty(Intl, 'DisplayNames', { configurable: true, writable: true, value: real })
}
})
})
describe('cityLine', () => {
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
name,
minutes: 60,
lastSeenAt: '2026-09-01T10:00:00Z',
}))
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
expect(cityLine(cities, 1)).toBe('Cologne')
expect(cityLine([])).toBe('')
})
})
describe('formatDistance', () => {
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
})
it('FE-DAWARICH-ATLASMODEL-014: exactly a kilometre is already a kilometre', () => {
// The switch is `< 1000`, so 1000 belongs to the other unit. Off by one here
// reads as "1000 m away", which is the phrasing the rule exists to avoid.
expect(formatDistance(1000, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
// One decimal, rounded, not truncated: 1049 and 1051 are not the same walk.
expect(formatDistance(1049, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
expect(formatDistance(1051, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.1"}')
})
})
@@ -0,0 +1,84 @@
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
/**
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
* the window arithmetic can be tested without rendering anything.
*/
/**
* A year back from the given moment.
*
* Long enough to cover the trips someone would be filling in, short enough that
* a self-hosted instance is not asked to aggregate a decade of points for a card
* nobody was looking at. Takes `now` rather than reading the clock so the window
* is testable.
*/
export function countryWindow(now: Date): { from: string; to: string } {
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
return { from: from.toISOString(), to: now.toISOString() }
}
/**
* The wishes worth showing, best first.
*
* Only entries the recordings actually found; the ones still open lead, because
* they are the ones the reader can act on, and within each group the most recent
* visit comes first — a wish reached last week is easier to confirm than one
* reached ten months ago.
*/
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
return matches
.filter(match => match.match !== null)
.slice()
.sort((a, b) => {
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
})
}
/** The countries a confirmation would actually change. */
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
return countries.filter(country => !country.alreadyVisited)
}
/**
* A country's name in the reader's language, falling back to Dawarich's own
* spelling.
*
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
* not know, so both are caught — the Atlas page's own resolver does the same.
*/
export function countryLabel(code: string, sourceName: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
} catch {
return sourceName
}
}
/**
* The first few cities, as one line.
*
* Three is what fits beside a flag at the width the dialog has on a phone, and
* the count badge beside it already says how many there were in total.
*/
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
return cities
.slice(0, limit)
.map(city => city.name)
.join(', ')
}
/**
* A distance a person would say out loud: metres up to a kilometre, then one
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
* far it was.
*/
export function formatDistance(
meters: number,
t: (key: string, params?: Record<string, unknown>) => string,
): string {
return meters < 1000
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
}
@@ -0,0 +1,215 @@
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014
import { describe, it, expect } from 'vitest'
import type { DawarichSuggestion } from '@trek/shared'
import {
clockOf,
formatDayHeading,
formatDayOption,
formatDuration,
groupByDay,
openStaysByDate,
timeRange,
} from './dawarichSuggestionModel'
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
return {
id: over.id,
sourceVisitId: String(over.id),
tripId: over.tripId ?? null,
tripTitle: over.tripTitle ?? null,
name: over.name ?? 'Cafe Reichard',
lat: over.lat ?? 50.94,
lng: over.lng ?? 6.96,
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
durationMinutes: over.durationMinutes ?? 145,
localDate: over.localDate,
sourceStatus: over.sourceStatus ?? 'suggested',
confidence: over.confidence ?? null,
confidenceBand: over.confidenceBand ?? null,
state: over.state ?? 'new',
target: over.target ?? null,
acceptedPlaceId: over.acceptedPlaceId ?? null,
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
sourceChanged: over.sourceChanged ?? false,
sourceMissing: over.sourceMissing ?? false,
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
matchedBucketListName: over.matchedBucketListName ?? null,
countryCode: over.countryCode ?? null,
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
}
}
const t = (key: string, params?: Record<string, unknown>): string =>
`${key}:${JSON.stringify(params ?? {})}`
describe('groupByDay', () => {
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
const days = groupByDay([
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-11' }),
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
])
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
})
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
const days = groupByDay([
stay({ id: 1, localDate: '2026-09-10' }),
stay({ id: 2, localDate: '2026-09-11' }),
])
const oldest = days.find(d => d.date === '2026-09-10')!
const newest = days.find(d => d.date === '2026-09-11')!
expect(oldest.color).not.toBe(newest.color)
// Same input in a different order paints the same day the same colour.
const again = groupByDay([
stay({ id: 2, localDate: '2026-09-11' }),
stay({ id: 1, localDate: '2026-09-10' }),
])
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
})
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
expect(groupByDay([])).toEqual([])
})
it('FE-DAWARICH-SUGMODEL-013: a day that already reads in order is left in it', () => {
// Three stays rather than two: with two, a comparator that answered the
// same thing both ways would still look right half the time.
const input = [
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T13:30:00+02:00' }),
]
const days = groupByDay(input)
expect(days).toHaveLength(1)
expect(days[0].stays.map(s => s.id)).toEqual([1, 3, 2])
// The list handed in belongs to the store. Sorting it in place would
// reorder what the panel is rendering from underneath it.
expect(input.map(s => s.id)).toEqual([1, 2, 3])
})
})
describe('clockOf / timeRange', () => {
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
})
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
expect(clockOf('yesterday')).toBe('')
expect(clockOf('')).toBe('')
})
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00', false)).toBe('10:15 – 12:40')
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense', false)).toBe('10:15')
})
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00', false)).toBe('')
})
it('FE-DAWARICH-SUGMODEL-012: a twelve-hour clock gets twelve-hour times, both ends of the range', () => {
// These were cut out of the timestamp and printed as they stood, so a traveller on a
// 12-hour clock read every Dawarich time in 24-hour while the rest of the app obeyed
// the setting.
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00', true)).toBe('10:15 AM – 12:40 PM')
expect(timeRange('2026-09-10T14:05:00+02:00', '2026-09-10T23:00:00+02:00', true)).toBe('2:05 PM – 11:00 PM')
// Midnight is 12 AM, not 0 AM, and noon is 12 PM.
expect(timeRange('2026-09-10T00:30:00+02:00', '2026-09-10T12:00:00+02:00', true)).toBe('12:30 AM – 12:00 PM')
})
})
describe('formatDuration', () => {
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
})
})
describe('formatDayHeading', () => {
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
// Built from the date parts rather than from a Date at midnight UTC, which
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
})
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
})
it('FE-DAWARICH-SUGMODEL-014: a date missing its month or its day is shown as it came too', () => {
// Each guard on its own, because `Date` would rather guess than refuse:
// month 0 rolls the heading back into December of the previous year, and a
// missing day silently becomes the first of the month. Either one reads as
// a real date, which is worse than showing the string that arrived.
expect(formatDayHeading('2026-00-10', 'en-GB')).toBe('2026-00-10')
expect(formatDayHeading('2026-09-00', 'en-GB')).toBe('2026-09-00')
expect(formatDayHeading('2026-09', 'en-GB')).toBe('2026-09')
})
})
describe('formatDayOption', () => {
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
expect(option.label).toBe('planner.dayN:{"n":3}')
expect(option.badge).toMatch(/10/)
})
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
})
})
describe('openStaysByDate', () => {
it('FE-DAWARICH-SUGMODEL-015: buckets the open stays by their own local day', () => {
// What a journal timeline asks for: it draws one day and folds that day's stays into
// it, so it looks them up by date rather than walking a flat list per day.
const byDate = openStaysByDate([
stay({ id: 1, localDate: '2026-09-10' }),
stay({ id: 2, localDate: '2026-09-11' }),
stay({ id: 3, localDate: '2026-09-10' }),
])
expect([...byDate.keys()].sort()).toEqual(['2026-09-10', '2026-09-11'])
expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([1, 3])
})
it('FE-DAWARICH-SUGMODEL-016: within a day they are in the order they were lived', () => {
// A run of stays reads as an afternoon only in that order; whatever order the server
// sent them in is not it.
const byDate = openStaysByDate([
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T16:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T08:30:00+02:00' }),
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T12:15:00+02:00' }),
])
expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([2, 3, 1])
})
it('FE-DAWARICH-SUGMODEL-017: only what is still waiting', () => {
// An accepted stay is an entry on that timeline already, and a dismissed one was waved
// away on purpose; offering either again is offering to do it twice.
const byDate = openStaysByDate([
stay({ id: 1, localDate: '2026-09-10', state: 'accepted' }),
stay({ id: 2, localDate: '2026-09-10', state: 'dismissed' }),
stay({ id: 3, localDate: '2026-09-10' }),
])
expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([3])
})
it('FE-DAWARICH-SUGMODEL-018: a day whose every stay is handled is not a day at all', () => {
// An empty bucket would draw a fold saying "0 stays" on a day that has none.
const byDate = openStaysByDate([stay({ id: 1, localDate: '2026-09-10', state: 'accepted' })])
expect(byDate.size).toBe(0)
})
})
@@ -0,0 +1,149 @@
import type { DawarichSuggestion } from '@trek/shared'
import { dayColor } from '../Map/dawarichTrail'
import { formatClockTime } from '../../utils/formatters'
/**
* Pure shaping for the suggestions panel — no React, so the grouping and the
* formatting can be tested without rendering anything.
*/
export interface SuggestionDay {
/** `YYYY-MM-DD`, the stay's own local date. */
date: string
/** The colour this day is drawn in on the map, so the panel and the line agree. */
color: string
stays: DawarichSuggestion[]
}
/**
* Group stays by the local day they happened on, newest day first, and give each
* day the colour the map draws it in.
*
* The grouping is what turns a flat list into something readable: a day of
* travel produces four or five stays, and repeating the full date on every row
* buries the one thing that distinguishes them — the time.
*
* The colour index counts days from the OLDEST, matching how the track overlay
* numbers them, so "the purple line" and "the purple day" are the same day.
*/
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
const byDate = new Map<string, DawarichSuggestion[]>()
for (const stay of suggestions) {
const bucket = byDate.get(stay.localDate)
if (bucket) bucket.push(stay)
else byDate.set(stay.localDate, [stay])
}
// ISO dates, so lexicographic order IS chronological order. Spelled out rather than
// left bare, and deliberately not locale-aware, which would vary by runtime.
const oldestFirst = [...byDate.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
return oldestFirst
.map((date, index) => ({
date,
color: dayColor(index),
// Within a day, earliest first: that is the order they were lived in.
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
}))
.reverse()
}
/**
* The stays still waiting, by the local day they happened on.
*
* What a journal timeline needs: it draws one day at a time and folds that day's stays
* into it, so it asks by date rather than walking a flat list per day. Only `new` ones —
* an accepted stay is already an entry on that timeline and a dismissed one was waved
* away on purpose — and within a day the order is the order they were lived in, which is
* what makes a run of them read as an afternoon.
*
* A Map rather than the day list `groupByDay` builds: that one is ordered newest-first for
* a panel to print, and this one is looked up by the day the timeline is already drawing.
*/
export function openStaysByDate(suggestions: DawarichSuggestion[]): Map<string, DawarichSuggestion[]> {
const byDate = new Map<string, DawarichSuggestion[]>();
for (const stay of suggestions) {
if (stay.state !== 'new') continue;
const bucket = byDate.get(stay.localDate);
if (bucket) bucket.push(stay);
else byDate.set(stay.localDate, [stay]);
}
for (const stays of byDate.values()) stays.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1));
return byDate;
}
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
export function clockOf(iso: string): string {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
}
/**
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
*
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
* next to times often enough that the difference is visible.
*
* `is12h` is not optional on purpose. These clocks were cut straight out of the
* timestamp and printed, so a traveller on a 12-hour clock read every Dawarich time in
* 24-hour while the rest of the app obeyed the setting. A required argument makes the
* next caller answer the question rather than inherit the bug.
*/
export function timeRange(startedAt: string, endedAt: string, is12h: boolean): string {
const from = formatClockTime(clockOf(startedAt), is12h)
const to = formatClockTime(clockOf(endedAt), is12h)
if (!from) return ''
return to ? `${from} – ${to}` : from
}
/**
* A duration a person would say out loud.
*
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
* somewhere. The two label keys carry the units so a locale can put them where
* it wants them.
*/
export function formatDuration(
minutes: number,
t: (key: string, params?: Record<string, unknown>) => string,
): string {
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest === 0
? t('dawarich.duration.hours', { hours })
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
}
/**
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
*
* Built from the date string rather than from a Date at midnight UTC, so a
* reader east or west of UTC does not see yesterday.
*/
export function formatDayHeading(date: string, locale: string): string {
const [year, month, day] = date.split('-').map(Number)
if (!year || !month || !day) return date
return new Date(year, month - 1, day).toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
/**
* How a trip day reads in the accept dialog's picker.
*
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
* number the planner gives the day and the weekday it fell on, which is exactly
* what the day rail shows them — so the picker says the same thing.
*/
export function formatDayOption(
dayNumber: number,
date: string | null | undefined,
locale: string,
t: (key: string, params?: Record<string, unknown>) => string,
): { label: string; badge?: string } {
return {
label: t('planner.dayN', { n: dayNumber }),
badge: date ? formatDayHeading(date, locale) : undefined,
}
}
@@ -0,0 +1,112 @@
/**
* FE-DAWARICH-API-030 to FE-DAWARICH-API-032
*
* Two lines of code, and both of them are the kind that rot without a sound.
*
* Accepting a recorded stay into a trip is written entirely server-side, so the
* planner learns nothing about the place it produced until something pulls the
* trip back in. This is that something, and both trip shells (the desktop
* `PlacesSidebar` and the phone `MPlacesBrowser`) call it as a one-liner
* precisely so the two copies cannot drift apart. What the tests hold is:
*
* - **Both refreshes are started, not one.** A stay accepted onto a day creates
* an assignment as well as a place, so refreshing only the places leaves the
* day rail one entry short until a reload. That is a plausible "simplification"
* for someone reading the call site, and nothing else in the app would notice.
* - **They run together, not in sequence.** `Promise.all` fires both before
* awaiting either; rewritten as two awaits it still works, just twice as
* slowly, and the button keeps spinning for both round trips. The order check
* below is synchronous, so it distinguishes the two shapes without timing.
* - **The id goes through verbatim.** The phone shell hands over a route param,
* which is a string; the desktop one hands over a number. Coercing either
* direction here would break the other caller.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useTripStore } from '../../store/tripStore'
import { refreshTripAfterAccept } from './dawarichTripRefresh'
const original = {
refreshPlaces: useTripStore.getState().refreshPlaces,
refreshDays: useTripStore.getState().refreshDays,
}
afterEach(() => {
useTripStore.setState(original)
})
describe('refreshTripAfterAccept', () => {
it('FE-DAWARICH-API-030: starts the day refresh without waiting for the place refresh', async () => {
const order: string[] = []
let releasePlaces!: () => void
// A gate rather than a timer: with Promise.all both calls are made before
// anything is awaited, so the day refresh has already run by the time control
// returns. Rewritten as `await a; await b` it would still be waiting here.
const placesGate = new Promise<void>(resolve => {
releasePlaces = resolve
})
useTripStore.setState({
refreshPlaces: vi.fn(async (_tripId: number | string) => {
order.push('places:called')
await placesGate
order.push('places:settled')
}),
refreshDays: vi.fn(async (_tripId: number | string) => {
order.push('days:called')
}),
})
const pending = refreshTripAfterAccept(7)
expect(order).toEqual(['places:called', 'days:called'])
releasePlaces()
await pending
expect(order).toEqual(['places:called', 'days:called', 'places:settled'])
})
it('FE-DAWARICH-API-031: resolves only once both refreshes have finished', async () => {
let releaseDays!: () => void
const daysGate = new Promise<void>(resolve => {
releaseDays = resolve
})
let settled = false
useTripStore.setState({
refreshPlaces: vi.fn(async (_tripId: number | string) => {}),
refreshDays: vi.fn(async (_tripId: number | string) => {
await daysGate
}),
})
const pending = refreshTripAfterAccept(7).then(() => {
settled = true
})
// Drain the microtask queue: the places half is already done, so anything
// that only awaited that one would be finished by now.
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
releaseDays()
await pending
expect(settled).toBe(true)
})
it('FE-DAWARICH-API-032: hands both refreshes the id exactly as the caller gave it', async () => {
const refreshPlaces = vi.fn(async (_tripId: number | string) => {})
const refreshDays = vi.fn(async (_tripId: number | string) => {})
useTripStore.setState({ refreshPlaces, refreshDays })
// The phone shell reads the trip id off the route, so it is a string there
// and a number on the desktop. Both repos accept either; a cast added here
// would only break whichever caller was not the one being looked at.
await refreshTripAfterAccept('42')
expect(refreshPlaces).toHaveBeenCalledWith('42')
expect(refreshDays).toHaveBeenCalledWith('42')
await refreshTripAfterAccept(42)
expect(refreshPlaces).toHaveBeenLastCalledWith(42)
expect(refreshDays).toHaveBeenLastCalledWith(42)
})
})
@@ -0,0 +1,18 @@
import { useTripStore } from '../../store/tripStore'
/**
* Pull the trip back in after a stay was accepted into it.
*
* The acceptance is written server-side, so the planner knows nothing about the
* place it produced until somebody reloads the page — which is exactly what a
* person should never have to do after pressing a button. Both trip shells call
* this, which is also why it is a plain function rather than a hook: one line at
* each call site keeps the desktop and phone copies from drifting.
*
* Places and days both, because a stay accepted onto a day creates an assignment
* as well, and the day rail would otherwise stay one place short.
*/
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
const store = useTripStore.getState()
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
}
@@ -1,10 +1,11 @@
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-038
import { render, screen, waitFor, fireEvent } 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 { useDocSyncOfferStore } from '../../store/docSyncOfferStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
@@ -104,6 +105,15 @@ beforeEach(() => {
}),
);
// Document sync, as a fresh install has it: every provider off, nothing bound.
useDocSyncOfferStore.setState({ bound: {}, providers: null });
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/connections', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/status', () => HttpResponse.json({ links: [], items: {} })),
);
// Stub window.confirm
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
@@ -631,4 +641,60 @@ describe('FileManager', () => {
expect(call[0]).toBeInstanceOf(FormData);
});
});
describe('document sync button', () => {
const paperless = { id: 'paperless', name: 'Paperless-ngx', description: null, icon: 'paperless', available: true, fields: [] };
const trashButton = () => screen.getByText('Trash').closest('button') as HTMLButtonElement;
it('FE-COMP-FILEMANAGER-036: with no provider on and nothing bound there is no sync button, and the trash sits flush right', async () => {
const asked: string[] = [];
server.use(
http.get('/api/trips/:tripId/docsync/links', () => {
asked.push('links');
return HttpResponse.json([]);
}),
);
render(<FileManager {...defaultProps} />);
await waitFor(() => expect(asked).toContain('links'));
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
expect(trashButton()).toHaveStyle({ marginLeft: 'auto' });
});
it('FE-COMP-FILEMANAGER-037: somebody who may bind the trip gets it once a provider is on, and it opens the panel', async () => {
server.use(http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless])));
render(<FileManager {...defaultProps} />);
const user = userEvent.setup();
const button = await screen.findByTitle('Document sync');
expect(button).toHaveStyle({ marginLeft: 'auto' });
expect(trashButton().style.marginLeft).toBe('');
await user.click(button);
expect(await screen.findByText('Connect a provider')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-038: a member sees it on a bound trip only', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true });
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless])),
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }])),
);
const { unmount } = render(<FileManager {...defaultProps} />);
expect(await screen.findByTitle('Document sync')).toBeInTheDocument();
unmount();
useDocSyncOfferStore.setState({ bound: {}, providers: null });
const asked: string[] = [];
server.use(
http.get('/api/trips/:tripId/docsync/links', () => {
asked.push('links');
return HttpResponse.json([]);
}),
);
render(<FileManager {...defaultProps} />);
await waitFor(() => expect(asked).toContain('links'));
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
});
});
});
@@ -7,6 +7,7 @@ import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView'
import DocSyncPanel from './docsync/DocSyncPanel'
export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
@@ -24,6 +25,19 @@ export default function FileManager(props: FileManagerProps) {
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Document sync opens as its own dialog: it is configuration for the
documents on this screen, so it belongs here rather than in settings,
but it is a task with a beginning and an end and does not belong
inside the list it configures. */}
{S.showDocSync && (
<DocSyncPanel
tripId={props.tripId}
tripTitle={S.trip?.title}
canManage={S.canManageSync}
onClose={() => S.setShowDocSync(false)}
/>
)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -1,8 +1,8 @@
import { Star, Trash2 } from 'lucide-react'
import { FolderSync, Star, Trash2 } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
export function FileManagerToolbar(S: FileManagerState) {
const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync, docSyncOffered } = S
return (
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
@@ -63,12 +63,29 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
{/* Opens the sync panel. Placed next to the trash rather than in a menu
because the question it answers, "where do these documents live",
belongs on the same screen as the documents. Only where there is
something behind it: see useDocSyncOffered. */}
{docSyncOffered && (
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
}}>
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
</button>
)}
{/* The first of the two takes the free space, so without the sync
button the trash sits flush right the way it always has. */}
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
flexShrink: 0, marginLeft: docSyncOffered ? undefined : 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -0,0 +1,423 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }))
const updateLink = vi.fn(async (_linkId: number, _patch: Record<string, unknown>) => {})
const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
connectionId: 1,
providerId: 'paperless',
scopeKey: 'trek-trip-3',
remoteLabel: 'Lisbon papers',
remoteRootPath: '/TREK/Lisbon',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 },
...overrides,
}
}
function makeSync(overrides: Partial<Sync> = {}): Sync {
return {
providers: [],
connections: [],
links: [],
itemCounts: {},
loading: false,
busy: null,
error: null,
connectionFor: () => null,
load: vi.fn(),
saveConnection: vi.fn(),
testConnection: vi.fn(),
loadScopes: vi.fn(),
createScope: vi.fn(),
createLink: vi.fn(),
updateLink,
removeLink,
syncNow,
resolveConflict: vi.fn(),
lastRunFor: () => ({ pulled: 0, pushed: 0 }),
...overrides,
} as unknown as Sync
}
function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
canManage?: boolean
providerName?: string
onReconnect?: () => void
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
providerName={opts.providerName ?? 'Paperless'}
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
return { ...utils, header }
}
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
const UNLINK_QUESTION = 'Documents stay in TREK and at the store. Only the pairing between them goes.'
/** The confirmation the disconnect button opens; it renders in a portal. */
const unlinkDialog = () => within(screen.getByText(UNLINK_QUESTION).closest('.trek-modal-enter') as HTMLElement)
/** Named through the same fallback chain `t()` uses, so the label can land in the locales later. */
const reconnectButton = () =>
screen.queryByRole('button', { name: (en as unknown as Record<string, string>)['docsync.binding.reconnect'] ?? 'docsync.binding.reconnect' })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding: running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
expect(syncNow).toHaveBeenCalledWith(4)
})
it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => {
renderCard({ sync: { busy: 'sync-4' } })
const button = screen.getByRole('button', { name: 'Syncing' })
expect(button).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
fireEvent.click(button)
expect(syncNow).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => {
renderCard({ sync: { busy: 'sync-9' } })
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-004: the disconnect button asks first and unlinks once that is confirmed', () => {
// As the phone does. The button sits beside "Sync now", and a binding
// cannot be put back: rebinding the same folder starts from nothing.
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.getByText(UNLINK_QUESTION)).toBeInTheDocument()
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Disconnect' }))
expect(removeLink).toHaveBeenCalledWith(4)
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-027: cancelling the question leaves the binding as it was', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Cancel' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
})
describe('DocSyncBinding: a refused credential', () => {
it('FE-DOCSYNC-BIND-028: offers to sign in again beside the notice, and hands the click to the panel', () => {
// The sidebar opens the credential form only for a store with no
// connection yet, so a rotated token had no way back in from the app.
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' }, onReconnect })
expect(screen.getByText('Sign in again · The credentials were refused.')).toBeInTheDocument()
fireEvent.click(reconnectButton() as HTMLElement)
expect(onReconnect).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-029: a refusal recorded as a failed run offers it too', () => {
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' }, onReconnect })
expect(reconnectButton()).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-030: is not offered to a member, for another failure, or while the provider is switched off', () => {
const onReconnect = vi.fn()
const refused = { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' } as const
const member = renderCard({ link: refused, onReconnect, canManage: false })
expect(reconnectButton()).not.toBeInTheDocument()
member.unmount()
const other = renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unreachable' }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
other.unmount()
// An admin has switched the store off: the paused notice stands in front,
// and a new credential would change nothing until it is back on.
const paused = renderCard({ link: { ...refused, providerOff: true }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
paused.unmount()
// The panel could not find the provider's form: nothing to open.
renderCard({ link: refused })
expect(reconnectButton()).not.toBeInTheDocument()
expect(onReconnect).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: what it says about itself', () => {
it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => {
renderCard()
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
const dot = screen.getByTitle('In sync')
expect(dot).toBeInTheDocument()
expect(dot).toBeEmptyDOMElement()
})
it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } })
// once as the header badge, once as the banner underneath it
expect(screen.getAllByText('Failed')).toHaveLength(2)
})
it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => {
renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } })
expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => {
renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } })
expect(screen.getAllByText('Not synced yet')).toHaveLength(1)
expect(screen.getByText('not run yet')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-026: a binding whose provider an admin switched off says it is paused, not what the last run said', () => {
// The server leaves the binding's own state as the last run wrote it, so
// without this the card went on reporting an old failure, or nothing.
renderCard({ link: { providerOff: true, lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
expect(screen.queryByText('Failed · The credentials were refused.')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
const { unmount } = renderCard({ link: { syncEnabled: false } })
expect(screen.getByText('Paused')).toBeInTheDocument()
unmount()
renderCard({ link: { syncEnabled: true } })
expect(screen.queryByText('Paused')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => {
renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } })
expect(screen.getByText(/hours ago/)).toBeInTheDocument()
expect(screen.queryByText('not run yet')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => {
const { unmount } = renderCard({ link: { remoteLabel: '' } })
expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument()
unmount()
renderCard({ link: { remoteLabel: '', remoteRootPath: null } })
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: names the store it is handed in the header and at the far end of the flow', () => {
const { header } = renderCard({ providerName: 'Paperless-ngx' })
expect(within(header).getByText('Paperless-ngx')).toBeInTheDocument()
expect(screen.getAllByText('Paperless-ngx')).toHaveLength(2)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => {
const { unmount } = renderCard()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
unmount()
renderCard({ link: { holdings: undefined } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
})
describe('DocSyncBinding: the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
const toggle = screen.getByRole('button', { name: 'Settings' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Sync automatically')).toBeInTheDocument()
expect(screen.getByText('When a document is deleted')).toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ canManage: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
// a member may still trigger a run
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => {
renderCard()
openSettings()
fireEvent.click(within(rowFor('Sync automatically')).getByRole('button'))
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false })
})
it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => {
renderCard({ link: { syncEnabled: false } })
openSettings()
const toggle = within(rowFor('Sync automatically')).getByRole('button')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true })
})
it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => {
renderCard()
openSettings()
const trigger = within(rowFor('When a document is deleted')).getByRole('button')
expect(trigger).toHaveTextContent('Keep both copies')
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' }))
expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' })
})
})
describe('DocSyncBinding: the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true })
})
const stubClipboard = (writeText: (text: string) => Promise<void>) => {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
}
it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => {
renderCard({ link: { webhookUrl: null } })
openSettings()
expect(screen.queryByText('Instant updates')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => {
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
expect(screen.getByText('Instant updates')).toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => {
const writeText = vi.fn(async (_text: string) => {})
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => {
const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') })
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
})
describe('DocSyncBinding: the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Out to the store' }))
expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' })
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ canManage: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
fireEvent.click(lane)
expect(updateLink).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,258 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
} from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
/**
* One binding: where this trip's documents live, which way they move, and what
* happened last time.
*
* The flow bar carries the direction because that is the one thing this feature
* is about. Everything a person sets once and forgets (the delete rule, the
* webhook URL) is folded away behind "Settings" so the card stays a status
* card rather than a form.
*/
export default function DocSyncBinding({
link,
providerName,
sync,
canManage,
onReconnect,
}: {
link: DocSyncLink
providerName: string
sync: ReturnType<typeof useDocSync>
canManage: boolean
/**
* Opens the credential form for this binding's store. Only the panel can,
* because the form needs the provider's field list, which the card does
* not carry; left out, a refused credential is reported but not curable.
*/
onReconnect?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [confirmUnlink, setConfirmUnlink] = useState(false)
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
const busy = sync.busy === `sync-${link.id}`
const notice = bindingNotice(link, t)
// Not while the provider is switched off: the paused notice stands in front
// of the refusal then, and a new credential would change nothing until an
// admin turns the provider back on.
const reconnect = canManage && onReconnect && !link.providerOff && needsReauth(link) ? onReconnect : null
const copyWebhook = async () => {
if (!link.webhookUrl) return
try {
await navigator.clipboard.writeText(link.webhookUrl)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch {
// Clipboard is blocked outside a secure context; the URL is selectable.
}
}
return (
<article className="overflow-hidden rounded-2xl border border-edge bg-surface shadow-card">
<header className="flex flex-wrap items-start gap-x-3 gap-y-3 px-4 py-3.5">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-edge bg-surface-secondary">
{Icon ? <Icon className="h-5 w-5 text-content" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-subtitle font-semibold text-content">
{providerName}
</span>
{/* A healthy binding needs no word for it: the dot is the whole
message, and the states that do need words get them below. */}
<StateBadge state={link.lastSyncState} compact={link.lastSyncState === 'ok'} />
{!link.syncEnabled && <Badge tone="neutral">{t('docsync.binding.autoOff')}</Badge>}
</span>
{/* Where and when, as two facts rather than a sentence. They are what
a member checks before asking why a file has not turned up. */}
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Badge tone="neutral" icon={<FolderOpen size={12} />} title={link.remoteRootPath || undefined}>
<span className="max-w-[12rem] truncate">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</span>
</Badge>
<Badge tone="neutral" icon={<Clock size={12} />}>
<LastRun at={link.lastSyncAt} />
</Badge>
</span>
</span>
{/* Both controls are h-9 so the square one cannot sit a pixel off the
other: the two together read as one segmented control. */}
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-9 items-center gap-2 rounded-lg border border-edge bg-surface px-3 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-60"
>
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />
{/* Taken out of the layout below sm rather than out of the document:
`hidden` left a screen reader announcing a bare "button" there,
and an aria-label would have frozen the name while the visible
text still changes to "Syncing". */}
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
</button>
{/* Asked first, as the phone does: the button sits a hand's width
from "Sync now", and a binding cannot be put back. Rebinding the
same folder starts from nothing and copies both sides over
again. */}
{canManage && (
<Tooltip label={t('docsync.unlink')}>
<button
type="button"
onClick={() => setConfirmUnlink(true)}
className="grid h-9 w-9 place-items-center rounded-lg border border-edge bg-surface text-content-muted transition-colors hover:bg-danger-soft hover:text-danger"
aria-label={t('docsync.unlink')}
>
<Link2Off size={14} />
</button>
</Tooltip>
)}
</span>
</header>
{notice && (
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-px shrink-0" />
<span className="min-w-0 flex-1">{notice}</span>
{/* The one state a person can act on from here. Without this the
only way past a rotated token was the API: the sidebar opens the
form for a store that has no connection yet, and this one has. */}
{reconnect && (
<button
type="button"
onClick={reconnect}
className="shrink-0 rounded-lg border border-edge bg-surface px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
>
{t('docsync.binding.reconnect')}
</button>
)}
</p>
)}
<ConfirmDialog
isOpen={confirmUnlink}
onClose={() => setConfirmUnlink(false)}
onConfirm={() => void sync.removeLink(link.id)}
title={t('docsync.unlink')}
message={t('docsync.confirmUnlink')}
confirmLabel={t('docsync.unlink')}
/>
<div className="px-4 pb-3">
<DocSyncFlow
direction={link.direction}
providerId={link.providerId}
providerName={providerName}
running={busy}
holdings={link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }}
disabled={!canManage}
onChange={(next: SyncDirection) => void sync.updateLink(link.id, { direction: next })}
/>
</div>
{canManage && (
<>
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-left text-caption text-content-muted transition-colors hover:bg-surface-hover"
>
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
{t('docsync.binding.settings')}
</button>
{open && (
<div className="space-y-3 border-t border-edge-faint bg-surface-secondary px-4 py-3">
<Row label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<CustomSelect
size="sm"
value={link.deletePolicy}
onChange={v => void sync.updateLink(link.id, { deletePolicy: String(v) })}
options={[
{ value: 'unlink', label: t('docsync.deleteUnlink') },
{ value: 'trash', label: t('docsync.deleteTrash') },
]}
/>
</Row>
<Row label={t('docsync.conflictPolicy')} hint={t('docsync.binding.conflictHint')}>
<CustomSelect
size="sm"
value={link.conflictPolicy}
onChange={v => void sync.updateLink(link.id, { conflictPolicy: String(v) })}
options={CONFLICT_POLICIES.map(p => ({ value: p, label: t(conflictPolicyKey(p)) }))}
/>
</Row>
<Row label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<span className="flex justify-end">
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</span>
</Row>
{/* Only where TREK could not subscribe itself. Papra's webhook CRUD
is closed to API keys and Nextcloud's needs admin rights, so the
person pastes this in by hand; polling carries it either way. */}
{link.webhookUrl && (
<div>
{/* Same tier as the two rows above it: this is the third
setting, not a footnote to the second. */}
<span className="block text-body text-content">{t('docsync.binding.webhookTitle')}</span>
<p className="mt-1 text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<div className="mt-1.5 flex items-stretch gap-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content-secondary">
{link.webhookUrl}
</code>
<button
type="button"
onClick={() => void copyWebhook()}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 text-caption text-content-secondary transition-colors hover:bg-surface-hover"
>
{copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
{copied ? t('docsync.binding.copied') : t('docsync.binding.copy')}
</button>
</div>
</div>
)}
</div>
)}
</>
)}
</article>
)
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-body text-content">{label}</span>
{hint && <span className="mt-0.5 block text-caption text-content-muted">{hint}</span>}
</span>
<span className="w-56 shrink-0">{children}</span>
</div>
)
}
@@ -0,0 +1,107 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { relativeTime } from '../../../utils/relativeTime'
/**
* The small shared pieces of the document-sync dialog.
*
* They live together because the panel, the store list and the binding card all
* label the same five sync states, and three hand-written copies of that mapping
* is how one of them ends up saying something different from the other two.
*/
export type Tone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'
const TONES: Record<Tone, string> = {
neutral: 'bg-surface-secondary text-content-secondary',
accent: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
}
/** A count or a short label, on a tinted pill. */
export function Badge({
tone = 'neutral',
icon,
children,
title,
}: {
tone?: Tone
icon?: ReactNode
children: ReactNode
title?: string
}) {
return (
<span
title={title}
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}
>
{icon}
{children}
</span>
)
}
/** The tone each sync state carries, in one place. */
function toneForState(state: string): Tone {
if (state === 'ok') return 'success'
if (state === 'partial') return 'warning'
if (state === 'never') return 'neutral'
return 'danger'
}
/**
* A binding's state as a dot plus its word.
*
* A dot alone is a colour puzzle and a word alone is easy to skim past; the two
* together survive both colour blindness and a glance.
*/
export function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
const { t } = useTranslation()
const tone = toneForState(state)
const dot =
tone === 'success' ? 'bg-success'
: tone === 'warning' ? 'bg-warning'
: tone === 'danger' ? 'bg-danger'
: 'bg-content-faint'
if (compact) return <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} title={t(`docsync.linkState.${state}`)} />
return (
<Badge tone={tone} icon={<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}>
{t(`docsync.linkState.${state}`)}
</Badge>
)
}
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <span title={new Date(ms).toLocaleString(locale)}>{relativeTime(ms, language)}</span>
}
/**
* The three answers to "both sides changed", in the order they are offered.
*
* Shared because the phone offers them as one cycling button and the desktop as
* a dropdown: two lists would drift, and a binding would then say one thing on
* a laptop and another on a phone.
*/
export const CONFLICT_POLICIES = ['manual', 'trek_wins', 'provider_wins'] as const
export type ConflictPolicy = (typeof CONFLICT_POLICIES)[number]
/** The next answer in that ring, for the phone's single button. */
export function nextConflictPolicy(current: string): ConflictPolicy {
const at = (CONFLICT_POLICIES as readonly string[]).indexOf(current)
return CONFLICT_POLICIES[(at + 1) % CONFLICT_POLICIES.length]
}
/** The label key for one answer. */
export function conflictPolicyKey(policy: string): string {
return `docsync.onConflict.${(CONFLICT_POLICIES as readonly string[]).includes(policy) ? policy : 'manual'}`
}
@@ -0,0 +1,494 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncProvider } from './useDocSync'
/** What the app really shows: `en` is bundled, so `t()` resolves for real here. */
const tx = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listConnections = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
status: (tripId: number | string) => status(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
},
}
})
import DocSyncConnectModal from './DocSyncConnectModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const nextcloud: DocSyncProvider = {
id: 'nextcloud',
name: 'Nextcloud',
description: 'Two-way document sync with a Nextcloud folder over WebDAV',
icon: 'Cloud',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: true, secret: false },
{ field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: true, secret: false },
{ field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: true, secret: true },
{ field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: false, secret: false },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
/** Synology is the one provider with a secret that is NOT a password box (the OTP). */
const synology: DocSyncProvider = {
id: 'synologydrive',
name: 'Synology Drive',
description: 'Two-way document sync with a folder on a Synology NAS',
icon: 'HardDrive',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: true, secret: false },
{ field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: true, secret: true },
{ field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: false, secret: true },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
const connection = (overrides: Partial<DocSyncConnection> = {}): DocSyncConnection => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
settings: { login_name: 'maurice', base_path: '/Archive/TREK' },
secrets: { app_password: 'stored' },
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
function Harness({
provider,
onClose,
onConnected,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
*/
waitForConnection?: boolean
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
}
/** Let the hook's initial load land before anything is asserted. */
async function flush() {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
}
async function open(provider: DocSyncProvider = nextcloud, waitForConnection = false) {
const onClose = vi.fn()
const onConnected = vi.fn()
const utils = render(
<Harness provider={provider} onClose={onClose} onConnected={onConnected} waitForConnection={waitForConnection} />,
)
await flush()
return { ...utils, onClose, onConnected }
}
const testButton = () => screen.getByRole('button', { name: tx('docsync.test') })
const connectButton = () => screen.getByRole('button', { name: tx('docsync.connect.submit') })
function fillNextcloud() {
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
}
beforeEach(() => {
vi.clearAllMocks()
providers.mockResolvedValue([])
listConnections.mockResolvedValue([])
listLinks.mockResolvedValue([])
status.mockResolvedValue({ items: {}, links: [] })
saveConnection.mockResolvedValue({ id: 1 })
testConnection.mockResolvedValue({ connected: true })
})
describe('DocSyncConnectModal form', () => {
it('FE-DOCSYNC-CONNECT-001: renders one labelled box per provider field and leaves the TLS field to the switch', async () => {
const { baseElement } = await open()
expect(screen.getByText(tx('docsync.providerUrl'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerUsername'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerAppPassword'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerBasePath'))).toBeInTheDocument()
// allow_insecure_tls is a switch, not a fifth text box
expect(baseElement.querySelectorAll('input')).toHaveLength(4)
expect(screen.getByText(tx('docsync.allowInsecureTls'))).toBeInTheDocument()
expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-002: a password field is masked and the other boxes are not', async () => {
await open()
expect(screen.getByPlaceholderText('app password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('username')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('/TREK')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-003: a secret the provider declares as plain text stays readable', async () => {
await open(synology)
expect(screen.getByPlaceholderText('password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('123456')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-004: only the optional fields carry the optional mark', async () => {
await open()
const optional = tx('docsync.connect.optional')
const basePath = screen.getByPlaceholderText('/TREK').closest('label') as HTMLElement
expect(within(basePath).getByText(optional)).toBeInTheDocument()
for (const placeholder of ['https://cloud.example.com', 'username', 'app password']) {
const field = screen.getByPlaceholderText(placeholder).closest('label') as HTMLElement
expect(within(field).queryByText(optional)).not.toBeInTheDocument()
}
expect(screen.getAllByText(optional)).toHaveLength(1)
})
it('FE-DOCSYNC-CONNECT-005: a field hint is translated, and a field without one gets no line', async () => {
await open()
const username = screen.getByPlaceholderText('username').closest('label') as HTMLElement
expect(within(username).getByText(tx('docsync.hintNextcloudLogin'))).toBeInTheDocument()
const baseUrl = screen.getByPlaceholderText('https://cloud.example.com').closest('label') as HTMLElement
expect(baseUrl.textContent?.trim()).toBe(tx('docsync.providerUrl'))
})
it('FE-DOCSYNC-CONNECT-006: the dialog is headed by the provider and explains what it will do', async () => {
await open()
expect(screen.getByRole('heading', { name: nextcloud.name })).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.about.nextcloud'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.insecureHint'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-007: escape closes the dialog without saving', async () => {
const { onClose, onConnected } = await open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
expect(onConnected).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
})
describe('DocSyncConnectModal required fields', () => {
it('FE-DOCSYNC-CONNECT-008: nothing can be saved or probed while a required field is empty', async () => {
await open()
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
fireEvent.click(connectButton())
fireEvent.click(testButton())
expect(saveConnection).not.toHaveBeenCalled()
expect(testConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-009: a missing password alone is enough to refuse the save', async () => {
await open()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
expect(connectButton()).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
expect(connectButton()).toBeEnabled()
})
it('FE-DOCSYNC-CONNECT-010: an address of nothing but spaces does not count as filled', async () => {
await open()
fillNextcloud()
expect(connectButton()).toBeEnabled()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: ' ' } })
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
})
it('FE-DOCSYNC-CONNECT-011: an empty optional field does not hold the save back', async () => {
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { base_url: 'https://cloud.example.com', login_name: 'maurice', app_password: 'app-pw' },
allowInsecureTls: false,
})
})
})
describe('DocSyncConnectModal probe verdict', () => {
it('FE-DOCSYNC-CONNECT-012: the probe carries the typed address, credentials and TLS choice', async () => {
await open()
fillNextcloud()
fireEvent.change(screen.getByPlaceholderText('/TREK'), { target: { value: '/Archive' } })
fireEvent.click(testButton())
await waitFor(() => expect(testConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {
base_url: 'https://cloud.example.com',
login_name: 'maurice',
app_password: 'app-pw',
base_path: '/Archive',
},
allowInsecureTls: false,
}))
expect(saveConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-013: a reachable provider names the account it signed in as', async () => {
testConnection.mockResolvedValueOnce({ connected: true, account: 'maurice' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = tx('docsync.connect.okAs').replace('{account}', 'maurice')
expect(await screen.findByText(line)).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-014: a reachable provider without an account just reports the connection', async () => {
testConnection.mockResolvedValueOnce({ connected: true })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.connected'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-015: a refused credential shows the translated reason', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'unauthorized' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.unauthorized'))
expect(line).toBeInTheDocument()
expect(screen.queryByText(tx('docsync.connected'))).not.toBeInTheDocument()
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).not.toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-016: an address TREK itself blocked is marked with the shield, not the warning', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'ssrf_blocked' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.ssrf_blocked'))
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-017: a failure with no reason falls back to the unknown message', async () => {
testConnection.mockResolvedValueOnce({ connected: false })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.unknown'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-018: a request that throws still lands as a verdict rather than an empty footer', async () => {
testConnection.mockRejectedValueOnce({ response: { data: { error: 'timeout' } } })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.timeout'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-019: while the probe runs the dialog says so and the probe button is locked', async () => {
let settle: (value: unknown) => void = () => {}
testConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(screen.getByText(tx('docsync.connect.testing'))).toBeInTheDocument()
expect(testButton()).toBeDisabled()
await act(async () => {
settle({ connected: true })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(screen.queryByText(tx('docsync.connect.testing'))).not.toBeInTheDocument()
expect(screen.getByText(tx('docsync.connected'))).toBeInTheDocument()
expect(testButton()).toBeEnabled()
})
})
describe('DocSyncConnectModal saving', () => {
it('FE-DOCSYNC-CONNECT-020: a successful save reports the connected provider upward', async () => {
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(onConnected).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
// the freshly stored connection is read back, not assumed
expect(listConnections).toHaveBeenCalledTimes(2)
})
it('FE-DOCSYNC-CONNECT-021: a rejected save reports nothing upward and leaves the form standing', async () => {
saveConnection.mockRejectedValueOnce({ response: { data: { error: 'unauthorized' } } })
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalled())
expect(onConnected).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('app password')).toHaveValue('app-pw')
})
it('FE-DOCSYNC-CONNECT-022: the save button is locked while the write is in flight', async () => {
let settle: (value: unknown) => void = () => {}
saveConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
expect(connectButton()).toBeDisabled()
await act(async () => {
settle({ id: 1 })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(onConnected).toHaveBeenCalledWith('nextcloud')
})
it('FE-DOCSYNC-CONNECT-023: the self-signed switch starts off and travels with the request', async () => {
await open()
fillNextcloud()
fireEvent.click(screen.getByRole('button', { pressed: false }))
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ allowInsecureTls: true })))
})
})
describe('DocSyncConnectModal with a connection already stored', () => {
it('FE-DOCSYNC-CONNECT-024: the stored settings come back, the saved secret is only hinted at, and it saves untouched', async () => {
listConnections.mockResolvedValue([connection({ settings: { login_name: 'maurice', base_path: '/Archive/TREK', app_password: 'never-echo-me' } })])
const { onConnected } = await open(nextcloud, true)
expect(await screen.findByPlaceholderText('••••••••')).toBeInTheDocument()
expect(screen.getByPlaceholderText('username')).toHaveValue('maurice')
expect(screen.getByPlaceholderText('/TREK')).toHaveValue('/Archive/TREK')
// a secret is never echoed back into the box, whatever the settings blob holds
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')
// the stored secret counts as filled, so nothing has to be retyped
expect(connectButton()).toBeEnabled()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {},
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-CONNECT-025: the stored self-signed choice is the one the switch starts on', async () => {
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, true)
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-026: a connection that lands after the dialog is open still sets the switch', async () => {
// The switch used to be seeded by a useState initialiser, so it only ever
// read the connection that existed at the moment the dialog mounted: a
// stored "allow self-signed" showed as off, and the next save wrote that
// back. A security switch turning itself off unasked is the bad version of
// a stale field, so it now reads through until somebody touches it.
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, false)
await screen.findByPlaceholderText('••••••••')
await waitFor(() => expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument())
})
it('FE-DOCSYNC-CONNECT-027: the stored address is shown as well as submitted', async () => {
listConnections.mockResolvedValue([connection()])
const { onConnected } = await open(nextcloud, true)
await screen.findByPlaceholderText('••••••••')
// The address is a column of its own rather than a settings entry. Reading
// it out of settings left the box empty over its placeholder while the form
// went on submitting the stored value, the field and the request quietly
// disagreeing about what the connection is.
await waitFor(() =>
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveValue('https://cloud.example.com'),
)
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
@@ -0,0 +1,189 @@
import { useState } from 'react'
import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react'
import Modal from '../../shared/Modal'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync'
/**
* Connecting a store, as a dialog rather than a panel that unfolds in place.
*
* Entering a URL and a token is a task with a beginning and an end, and doing it
* inside the list meant the list jumped around while someone typed. The dialog
* also gives the probe result somewhere to live that is not a line of text
* squeezed between two fields.
*/
export default function DocSyncConnectModal({
provider,
sync,
onClose,
onConnected,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const test = async () => setVerdict(await form.probe())
const save = async () => {
if (await form.save()) onConnected(provider.id)
}
return (
<Modal
isOpen
onClose={onClose}
size="md"
title={
<span className="flex items-center gap-2.5">
{Icon && <Icon className="h-5 w-5 text-content" />}
<span>{provider.name}</span>
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void test()}
disabled={!form.complete || sync.busy === 'test'}
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.test')}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!form.complete || sync.busy === 'save'}
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
{t('docsync.connect.submit')}
</button>
</div>
</div>
}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{form.fields.map(f => (
<Field
key={f.field_key}
label={t(form.labelKey(f))}
hint={form.hintKey(f) ? t(form.hintKey(f) as string) : undefined}
required={f.required}
>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={form.valueOf(f.field_key)}
placeholder={form.placeholderOf(f)}
onChange={e => form.setValue(f.field_key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2"
/>
</Field>
))}
<label className="flex items-start justify-between gap-4 rounded-lg border border-edge bg-surface-secondary px-3 py-2.5">
<span className="min-w-0">
<span className="block text-body text-content">{t('docsync.allowInsecureTls')}</span>
<span className="mt-0.5 block text-caption text-content-muted">
{t('docsync.connect.insecureHint')}
</span>
</span>
<span className="shrink-0 pt-0.5">
<ToggleSwitch on={form.insecureTls} onToggle={() => form.setInsecureTls(!form.insecureTls)} />
</span>
</label>
</div>
</Modal>
)
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<label className="block">
<span className="mb-1.5 flex items-center gap-2">
<span className="text-body font-medium text-content">{label}</span>
{!required && <Badge tone="neutral">{t('docsync.connect.optional')}</Badge>}
</span>
{children}
{hint && <span className="mt-1.5 block text-caption text-content-muted">{hint}</span>}
</label>
)
}
/** The probe result, in the footer where it stays put while the form scrolls. */
function Verdict({
verdict,
busy,
failure,
}: {
verdict: { connected: boolean; account?: string; error?: string } | null
busy: boolean
/** A call that failed outside the connection test (saving, most of all). */
failure: string | null
}) {
const { t } = useTranslation()
if (busy) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
<Loader2 size={14} className="animate-spin" />
{t('docsync.connect.testing')}
</span>
)
}
// A save that the server refused left the dialog open with the button back to
// normal and nothing else said, which reads as nothing having happened. The
// test verdict wins the space when there is one: it is the more specific
// answer and the one the person just asked for.
if (!verdict && failure) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
<AlertTriangle size={14} />
<span className="truncate">{t(`docsync.error.${failure}`)}</span>
</span>
)
}
if (!verdict) return <span />
if (verdict.connected) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-success">
<Check size={14} strokeWidth={2.5} />
<span className="truncate">
{verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')}
</span>
</span>
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
)
}
@@ -0,0 +1,214 @@
// FE-DOCSYNC-FLOW-001 to FE-DOCSYNC-FLOW-013
/**
* The direction control of the document-sync dialog.
*
* Two lanes, each one a direction that is either carrying or idle. The rules
* that matter to somebody using it: what each side is holding is readable
* before a word is, switching direction means switching a lane off, and the
* last lane cannot be switched off, a binding with no direction syncs
* nothing, and a dialog that lets you build one has lied about what it does.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent } from '../../../../tests/helpers/render'
import DocSyncFlow, { type FlowHoldings, type SyncDirection } from './DocSyncFlow'
// The captions come from the locale rather than from English typed out here:
// the assertion is "the lane labelled as the outbound one", not the wording.
const S = en as unknown as Record<string, string>
const OUT = S['docsync.flow.toProvider']
const IN = S['docsync.flow.toTrek']
const onChange = vi.fn()
const holdings: FlowHoldings = { inTrek: 12, atProvider: 7, paired: 5, missing: 2 }
function renderFlow(
props: Partial<{
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
running: boolean
disabled: boolean
}> = {},
) {
return render(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
{...props}
/>,
)
}
const laneOut = () => screen.getByRole('button', { name: OUT })
const laneIn = () => screen.getByRole('button', { name: IN })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncFlow ends', () => {
it('FE-DOCSYNC-FLOW-001: shows what each side is holding, next to the name of that side', () => {
renderFlow()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText(S['docsync.flow.trek'])).toBeInTheDocument()
expect(screen.getByText('Paperless')).toBeInTheDocument()
})
it('FE-DOCSYNC-FLOW-002: an empty side reads as zero rather than as nothing', () => {
// A healthy binding transfers nothing; the panel must still answer the
// question somebody opened it for.
renderFlow({ holdings: { inTrek: 0, atProvider: 0, paired: 0, missing: 0 } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
it('FE-DOCSYNC-FLOW-003: an unrecognised store still names itself', () => {
renderFlow({ providerId: 'not-a-provider', providerName: 'Some Store' })
expect(screen.getByText('Some Store')).toBeInTheDocument()
expect(laneOut()).toBeInTheDocument()
})
})
describe('DocSyncFlow direction switching', () => {
it('FE-DOCSYNC-FLOW-004: switching the outbound lane off from both leaves inbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('pull')
})
it('FE-DOCSYNC-FLOW-005: switching the inbound lane off from both leaves outbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('push')
})
it('FE-DOCSYNC-FLOW-006: switching the idle inbound lane on from push gives both ways', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-007: switching the idle outbound lane on from pull gives both ways', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-008: the last carrying lane refuses to switch off (push)', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneOut())
expect(onChange).not.toHaveBeenCalled()
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-009: the last carrying lane refuses to switch off (pull)', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-010: a member who may not edit the binding cannot move the lanes', () => {
renderFlow({ direction: 'both', disabled: true })
expect(laneOut()).toBeDisabled()
expect(laneIn()).toBeDisabled()
fireEvent.click(laneOut())
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
})
})
describe('DocSyncFlow lane state', () => {
it('FE-DOCSYNC-FLOW-011: aria-pressed mirrors each lane in every direction', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'false')
rerender(
<DocSyncFlow
direction="pull"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'false')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-012: only the switched-off lane is hatched', () => {
// The hatch is the whole of "this road is not driven on", there is no
// other handle for it, so the class is the behaviour here.
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).not.toHaveClass('trek-docsync-lane-off')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).toHaveClass('trek-docsync-lane-off')
})
it('FE-DOCSYNC-FLOW-013: the hint tells an editor the lanes are tappable, a reader only what happens', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(screen.getByText(S['docsync.flow.summaryEditable.both'])).toBeInTheDocument()
rerender(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
disabled
onChange={onChange}
/>,
)
expect(screen.getByText(S['docsync.flow.summary.both'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.flow.summaryEditable.both'])).not.toBeInTheDocument()
})
})
@@ -0,0 +1,213 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import TrekIcon from '../../shared/TrekIcon'
import { Badge } from './DocSyncBits'
export type SyncDirection = 'both' | 'pull' | 'push'
export interface FlowHoldings {
/** Documents this trip has in TREK. */
inTrek: number
/** How many of them the store is holding. */
atProvider: number
paired: number
missing: number
}
/**
* The direction control, drawn as what it actually is.
*
* A document sync has one characteristic picture (two places and things moving
* between them), and a dropdown reading "Both ways" hides it behind a word.
* Here the two ends carry what each side is holding, the lanes between them
* carry the direction, and switching direction means switching a lane off. It
* is the one loud element in the dialog; everything around it stays quiet.
*
* The numbers are standing counts, not the last run's transfers: a binding that
* is already in step transferred nothing, and a panel answering "0" on a
* healthy connection says nothing at the moment somebody looks at it.
*/
export default function DocSyncFlow({
direction,
providerId,
providerName,
holdings,
running,
disabled,
onChange,
}: {
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
/** A run is in flight: the active lanes move while it is. */
running?: boolean
disabled?: boolean
onChange: (next: SyncDirection) => void
}) {
const { t } = useTranslation()
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
const pushOn = direction === 'both' || direction === 'push'
const pullOn = direction === 'both' || direction === 'pull'
const hint = disabled
? t(`docsync.flow.summary.${direction}`, { provider: providerName })
: t(`docsync.flow.summaryEditable.${direction}`, { provider: providerName })
/** Turning a lane off leaves the other one; turning the last one off is refused. */
const toggle = (lane: 'push' | 'pull') => {
if (disabled) return
const nextPush = lane === 'push' ? !pushOn : pushOn
const nextPull = lane === 'pull' ? !pullOn : pullOn
if (!nextPush && !nextPull) return
onChange(nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull')
}
return (
<section className="rounded-2xl border border-edge bg-surface-secondary p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-5">
<End
name={t('docsync.flow.trek')}
count={holdings.inTrek}
glyph={<TrekIcon className="h-6 w-6 text-content" />}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<Lane
active={pushOn}
running={running}
disabled={disabled}
caption={t('docsync.flow.toProvider')}
onClick={() => toggle('push')}
>
<ArrowRight size={13} strokeWidth={2.75} />
</Lane>
<Lane
active={pullOn}
running={running}
reverse
disabled={disabled}
caption={t('docsync.flow.toTrek')}
onClick={() => toggle('pull')}
>
<ArrowLeft size={13} strokeWidth={2.75} />
</Lane>
{/* Under the lanes rather than under the whole bar: it explains what
the two buttons above it are doing, and sitting anywhere else
makes the reader look for what it refers to. */}
<p className="truncate px-1 text-center text-caption text-content-muted">{hint}</p>
</div>
<End
name={providerName}
count={holdings.atProvider}
glyph={Icon ? <Icon className="h-6 w-6 text-content" /> : null}
/>
</div>
</section>
)
}
/**
* One end of the flow: the mark, the count, the name.
*
* The count is the largest thing in the box on purpose: "is my stuff over
* there" is the question that makes somebody open this dialog, and it should be
* answered before they read a word.
*/
function End({ name, count, glyph }: { name: string; count: number; glyph: React.ReactNode }) {
const { t } = useTranslation()
return (
<div
className="flex w-24 shrink-0 flex-col items-center gap-2 rounded-2xl border border-edge bg-surface px-3 py-4 shadow-card sm:w-32"
>
<span className="grid h-10 w-10 place-items-center rounded-xl bg-surface-secondary">{glyph}</span>
<span className="flex flex-col items-center gap-1">
<span className="text-title font-semibold tabular-nums leading-none text-content">{count}</span>
<span className="text-caption uppercase leading-none tracking-wide text-content-faint">
{t('docsync.flow.documents')}
</span>
</span>
<Badge tone="neutral" title={name}>
<span className="max-w-[6.5rem] truncate">{name}</span>
</Badge>
</div>
)
}
/**
* One direction, as a track that is either carrying or idle.
*
* An idle track stays visible as a dashed line, so switching a direction off
* reads as a road nobody drives on rather than as something that disappeared.
*/
function Lane({
active,
running,
disabled,
caption,
reverse,
onClick,
children,
}: {
active: boolean
running?: boolean
disabled?: boolean
caption: string
reverse?: boolean
onClick: () => void
children: React.ReactNode
}) {
const moving = active && running
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={[
// No border at all, not a transparent one: a border box across the
// diagonals still renders a hairline where it meets the corner radius,
// and that line reads as a broken frame rather than as a hatch.
'group relative flex h-12 items-center gap-3 overflow-hidden rounded-xl px-3 text-caption transition-all duration-200',
reverse ? 'flex-row-reverse' : '',
active
? 'bg-accent-subtle text-accent-on'
: 'trek-docsync-lane-off bg-surface text-content-faint',
disabled ? 'cursor-default' : active ? 'hover:brightness-[0.98]' : 'hover:text-content-muted',
].join(' ')}
>
<span
className={[
'relative grid h-7 w-7 shrink-0 place-items-center rounded-lg transition-transform duration-200',
active ? 'bg-surface shadow-sm' : 'bg-surface-secondary',
disabled ? '' : reverse ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5',
].join(' ')}
>
{children}
</span>
{/* Explicitly aligned to the arrow's side: the dialog inherits a centred
text-align, which would otherwise float the label away from it. */}
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
{caption}
</span>
{/* Only while a run is in flight. An idle lane needs no track (that it is
on or off is already in its fill), and a permanent line just asks the
reader what it means. */}
{moving && (
<span
aria-hidden
className={`trek-docsync-lane-run pointer-events-none absolute inset-x-3 bottom-1.5 h-0.5 rounded-full ${
reverse ? 'trek-docsync-lane-run-rev' : ''
}`}
/>
)}
</button>
)
}
@@ -0,0 +1,528 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022
/**
* The document-sync dialog shell (#2391).
*
* The panel itself owns three decisions: which store is open, which of them a
* person is even offered, and whether the "needs a look" list is worth the
* space. Everything below it (the binding card, the flow bar, the connect and
* scope modals) is tested where it lives; what is exercised here is the shell
* around them, driven through the real `useDocSync` against a mocked
* `docsyncApi`, because the selection rules are a reaction to what the server
* answers rather than to a prop.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { act, render, screen, within, fireEvent, waitFor } from '../../../../tests/helpers/render'
/** What the dialog actually renders for a key, via the same fallback chain `t()` uses. */
const t = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn()
const listConnections = vi.fn()
const listLinks = vi.fn()
const status = vi.fn()
const deleteLink = vi.fn()
const updateLink = vi.fn()
const syncNow = vi.fn()
const items = vi.fn()
const resolve = vi.fn()
const saveConnection = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
status: (tripId: number | string) => status(tripId),
deleteLink: (tripId: number | string, linkId: number) => deleteLink(tripId, linkId),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLink(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNow(tripId, linkId, full),
items: (tripId: number | string, state?: string) => items(tripId, state),
resolve: (tripId: number | string, itemId: number, keep: string) => resolve(tripId, itemId, keep),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
},
}
})
import DocSyncPanel from './DocSyncPanel'
import type { DocSyncLink, DocSyncProvider } from './useDocSync'
const provider = (id: string, name: string, available = true): DocSyncProvider => ({
id,
name,
description: null,
icon: id,
available,
fields: [],
})
const link = (id: number, providerId: string, overrides: Partial<DocSyncLink> = {}): DocSyncLink => ({
id,
connectionId: id,
providerId,
scopeKey: `scope-${id}`,
remoteLabel: `Folder ${id}`,
remoteRootPath: `/TREK/folder-${id}`,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** What the server answers, as one object the tests reshape per case. */
function serverHas({
providers: p = [],
connections = [],
links = [],
items = {},
}: {
providers?: DocSyncProvider[]
connections?: Array<Record<string, unknown>>
links?: DocSyncLink[]
items?: Record<string, number>
}): void {
providers.mockResolvedValue(p)
listConnections.mockResolvedValue(connections)
listLinks.mockResolvedValue(links)
status.mockResolvedValue({
items,
links: links.map(l => ({ id: l.id, holdings: { inTrek: 3, atProvider: 3, paired: 3, missing: 0 } })),
})
}
/** The stored connection a binding runs under. */
const connection = (id: number, providerId: string) => ({
id,
providerId,
baseUrl: 'https://docs.example.org',
settings: {},
secrets: { token: '***' },
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(r => { resolve = r })
return { promise, resolve }
}
const onClose = vi.fn()
/** The detail column's binding card. */
const binding = () => screen.getByRole('article')
/** The store list beside it. */
const sidebar = () => screen.getByRole('navigation')
/** The stores filed under one sidebar heading. */
const group = (heading: string) =>
within(screen.getByRole('heading', { name: heading }).parentElement as HTMLElement)
/** The "needs a look" section under the binding card. */
const issues = () =>
within(screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement)
/** Disconnect asks first; this answers yes. The question renders in a portal. */
const confirmUnlink = () => {
const dialog = screen.getByText(t('docsync.confirmUnlink')).closest('.trek-modal-enter') as HTMLElement
fireEvent.click(within(dialog).getByRole('button', { name: t('docsync.unlink') }))
}
beforeEach(() => {
vi.clearAllMocks()
serverHas({})
deleteLink.mockResolvedValue({})
updateLink.mockResolvedValue({})
syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
items.mockResolvedValue([])
resolve.mockResolvedValue({ success: true })
saveConnection.mockResolvedValue({ id: 1 })
})
describe('DocSyncPanel: first load', () => {
it('FE-DOCSYNC-PANEL-001: shows a spinner while the first load is in flight and nothing else', async () => {
const gate = deferred<DocSyncProvider[]>()
providers.mockReturnValue(gate.promise)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// The spinner carries no text of its own; the spin is the whole affordance.
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument()
await act(async () => { gate.resolve([]) })
})
it('FE-DOCSYNC-PANEL-002: replaces the spinner with the store list once the load lands', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByRole('navigation')).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
})
it('FE-DOCSYNC-PANEL-003: says there is nothing to configure when the instance offers no provider', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.noProvidersHint'))).toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-017: a bound trip keeps showing its binding after every provider was switched off', async () => {
serverHas({ providers: [], links: [link(1, 'paperless', { providerName: 'Paperless-ngx' })] })
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
expect(await screen.findByRole('article')).toBeInTheDocument()
expect(within(sidebar()).getByText('Folder 1')).toBeInTheDocument()
expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument()
// The providers route no longer names it, so the name comes off the link.
expect(within(sidebar()).getByText('Paperless-ngx')).toBeInTheDocument()
expect(within(binding()).getAllByText('Paperless-ngx').length).toBeGreaterThan(0)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-018: a reload that finds the addon gone blanks the bindings too', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
// Documents is switched off while the dialog is open: the run is refused
// and so is every route the reload after it asks.
const gone = { response: { status: 404 } }
syncNow.mockImplementation(async () => {
providers.mockRejectedValue(gone)
throw gone
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') }))
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-004: a provider switched on without a working adapter is not offered', async () => {
serverHas({ providers: [provider('papra', 'Papra', false)] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.queryByText('Papra')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: the store list', () => {
it('FE-DOCSYNC-PANEL-005: files the bound stores under one heading and the rest under another', async () => {
serverHas({
providers: [
provider('paperless', 'Paperless-ngx'),
provider('nextcloud', 'Nextcloud'),
provider('opencloud', 'OpenCloud'),
],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
const connected = group(t('docsync.sidebar.connected'))
expect(connected.getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(connected.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
const addable = group(t('docsync.addAnother'))
expect(addable.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument()
expect(addable.getByRole('button', { name: /OpenCloud/ })).toBeInTheDocument()
expect(addable.queryByRole('button', { name: /Paperless-ngx/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-006: with nothing bound the list invites a first connection instead of another one', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
expect(screen.getByRole('heading', { name: t('docsync.addProvider') })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.sidebar.connected') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-007: opens the first bound store without being asked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
const card = await screen.findByRole('article')
// The card names its provider twice (header and flow bar), so the folder is
// what says which of the two bindings is open.
expect(within(card).getByText('Folder 1')).toBeInTheDocument()
expect(within(card).queryByText('Folder 2')).not.toBeInTheDocument()
expect(within(card).getAllByText('Paperless-ngx').length).toBeGreaterThan(0)
expect(within(card).queryByText('Nextcloud')).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-008: picking a store swaps the detail column and moves the marker with it', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const second = within(sidebar()).getByRole('button', { name: /Nextcloud/ })
fireEvent.click(second)
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
expect(within(binding()).getAllByText('Nextcloud').length).toBeGreaterThan(0)
expect(within(binding()).queryByText('Paperless-ngx')).not.toBeInTheDocument()
expect(second).toHaveAttribute('aria-current', 'true')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current')
})
})
describe('DocSyncPanel: a member rather than the owner', () => {
it('FE-DOCSYNC-PANEL-009: is shown where the documents go but is offered no store to add', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
await screen.findByRole('navigation')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(within(sidebar()).queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-010: with nothing bound is told the owner sets this up, not to pick a store', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
expect(await screen.findByText(t('docsync.empty.hintMember'))).toBeInTheDocument()
expect(screen.queryByText(t('docsync.empty.hintOwner'))).not.toBeInTheDocument()
expect(within(sidebar()).queryAllByRole('button')).toHaveLength(0)
})
})
describe('DocSyncPanel: removing a binding', () => {
it('FE-DOCSYNC-PANEL-011: moves the selection to the binding that is left instead of emptying the column', async () => {
const paperless = link(1, 'paperless')
const nextcloud = link(2, 'nextcloud')
const both = [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')]
serverHas({ providers: both, links: [paperless, nextcloud] })
// The unlink lands on the server, and the reload after it is what the panel
// reacts to, so that is where the row disappears.
deleteLink.mockImplementation(async () => {
serverHas({ providers: both, links: [paperless] })
return {}
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
confirmUnlink()
await waitFor(() => expect(deleteLink).toHaveBeenCalledWith(7, 2))
await waitFor(() => expect(within(binding()).getByText('Folder 1')).toBeInTheDocument())
expect(screen.queryByText(t('docsync.empty.title'))).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-012: removing the last binding leaves the invitation, not a blank column', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
deleteLink.mockImplementation(async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
return {}
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
confirmUnlink()
expect(await screen.findByText(t('docsync.empty.title'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.empty.hintOwner'))).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: what needs a look', () => {
it('FE-DOCSYNC-PANEL-013: stays quiet when nothing needs attention', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')], items: {} })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-014: counts that need no decision do not open the issues panel', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { pending: 9, synced: 41, local_deleted: 2 },
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-015: names only the states needing attention, with their counts', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 2, too_large: 1, pending: 9, synced: 41 },
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const panel = within(
screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement,
)
const rows = panel.getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(within(rows[0]).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
expect(within(rows[0]).getByText(t('docsync.issues.conflict'))).toBeInTheDocument()
// The conflict row carries a button rather than a bare count: it is the one
// of these a person can act on.
expect(within(rows[0]).getByRole('button', { name: /Resolve/ })).toBeInTheDocument()
expect(within(rows[1]).getByText(t('docsync.state.too_large'))).toBeInTheDocument()
expect(within(rows[1]).getByText('1')).toBeInTheDocument()
expect(panel.queryByText(t('docsync.state.pending'))).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-019: a member is shown the conflict count but offered no choice', async () => {
// The server refuses a member's choice with 403, so the three buttons
// would only ever fail for them. The count is what they are owed.
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 2 },
})
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
await screen.findByRole('article')
const row = issues().getAllByRole('listitem')[0]
expect(within(row).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
expect(within(row).getByText('2')).toBeInTheDocument()
expect(within(row).queryByRole('button')).not.toBeInTheDocument()
expect(items).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-PANEL-020: a choice the server refuses is said in the column, and the row stays', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 1 },
})
items.mockResolvedValue([{ id: 40, file_name: 'boarding-pass.pdf', remote_name: 'boarding-pass.pdf' }])
// Settled by the owner in another tab a moment ago.
resolve.mockRejectedValue({ response: { status: 400, data: { error: 'conflict' } } })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(issues().getByRole('button', { name: /Resolve/ }))
fireEvent.click(await issues().findByRole('button', { name: t('docsync.conflict.keepTrek') }))
await waitFor(() => expect(resolve).toHaveBeenCalledWith(7, 40, 'trek'))
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.conflict'))
// The list is read back rather than trimmed by hand, so the row is still
// there, ready for another try.
await waitFor(() => expect(issues().getByRole('button', { name: t('docsync.conflict.keepTrek') })).toBeEnabled())
expect(issues().getByText('boarding-pass.pdf')).toBeInTheDocument()
expect(items).toHaveBeenCalledTimes(2)
})
})
describe('DocSyncPanel: what a click on the card reports', () => {
it('FE-DOCSYNC-PANEL-021: a refused run is said under the card rather than swallowed', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
syncNow.mockRejectedValue({ response: { status: 409, data: { error: 'provider_disabled' } } })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') }))
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.provider_disabled'))
expect(screen.getByRole('article')).toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-022: a refused credential opens the form for the bound store, and saving runs the binding', async () => {
// The sidebar only ever opened the form for a store with no connection
// yet, and a bound store is not listed there, so a rotated token could
// only be entered through the API.
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
connections: [connection(5, 'paperless')],
links: [link(1, 'paperless', { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.binding.reconnect') }))
// The credential form, for this store, not the folder picker.
expect(screen.getByRole('button', { name: t('docsync.connect.submit') })).toBeInTheDocument()
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: t('docsync.connect.submit') }))
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(7, expect.objectContaining({ providerId: 'paperless' })))
await waitFor(() => expect(syncNow).toHaveBeenCalledWith(7, 1, false))
await waitFor(() => expect(screen.queryByRole('button', { name: t('docsync.connect.submit') })).not.toBeInTheDocument())
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: the dialog itself', () => {
it('FE-DOCSYNC-PANEL-016: names the trip under the title and closes from the header', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} tripTitle="Iceland 2026" canManage onClose={onClose} />)
await screen.findByText(t('docsync.noProviders'))
expect(screen.getByText(t('docsync.title'))).toBeInTheDocument()
expect(screen.getByText('Iceland 2026')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { StateBadge } from './DocSyncBits'
import { useConflicts } from './useConflicts'
import { storeName, useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync'
import DocSyncBinding from './DocSyncBinding'
import DocSyncConnectModal from './DocSyncConnectModal'
import DocSyncScopeModal from './DocSyncScopeModal'
/**
* Document sync for one trip, as a dialog with the stores down one side and the
* selected one open beside it.
*
* Two columns rather than a stack, because the two questions are different
* shapes: "which store" is a short list somebody scans, and "how does this one
* behave" is a screenful. Stacked, the second answer pushed the first one off
* the top every time a binding was added.
*
* It opens from the file manager rather than from settings: this is the one
* screen where a person has the context that makes the folder choice obvious.
* Only the trip owner can change a binding (the credential usually reaches
* their whole archive), but every member sees where their documents go, which
* is the minimum a shared folder owes the people sharing it.
*/
export default function DocSyncPanel({
tripId,
tripTitle,
canManage,
onClose,
}: {
tripId: number | string
tripTitle?: string
canManage: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const sync = useDocSync(tripId, true)
const [selected, setSelected] = useState<number | null>(null)
const [connecting, setConnecting] = useState<DocSyncProvider | null>(null)
const [scopeFor, setScopeFor] = useState<string | null>(null)
// The credential form again, for a store this trip is already bound to. Kept
// apart from `connecting`, whose next step is the folder picker: here the
// folder is known and the next step is the run that the refusal stopped.
const [reconnecting, setReconnecting] = useState<DocSyncProvider | null>(null)
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
const available = sync.providers.filter(p => !bound.has(p.id))
const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null
// Undefined once an admin has switched the store off: the form needs the
// provider's field list, and the providers route no longer carries it.
const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined
// Follow the list: a freshly bound store should be the one on screen, and a
// removed one must not leave the detail column pointing at nothing.
useEffect(() => {
if (sync.links.length === 0) setSelected(null)
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id)
}, [sync.links, selected])
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
return (
<>
<Modal
isOpen
onClose={onClose}
size="4xl"
title={
<span className="flex flex-col">
<span>{t('docsync.title')}</span>
{tripTitle && (
<span className="mt-0.5 truncate text-caption font-normal text-content-muted">{tripTitle}</span>
)}
</span>
}
>
{sync.loading ? (
<div className="grid place-items-center py-20">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
) : sync.providers.length === 0 && sync.links.length === 0 ? (
// Only with nothing bound: a binding outlives the admin switching its
// provider off, and the people sharing it still need to see it.
<Empty />
) : (
<div className="grid gap-5 md:grid-cols-[17rem_minmax(0,1fr)] md:gap-6">
<Sidebar
links={sync.links}
providers={sync.providers}
available={available}
activeId={active?.id ?? null}
canManage={canManage}
onSelect={setSelected}
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
<div className="min-w-0 md:border-l md:border-edge-faint md:pl-6">
{active ? (
<div className="space-y-5">
<DocSyncBinding
link={active}
providerName={storeName(active, sync.providers)}
sync={sync}
canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
/>
{/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not
settle. The two dialogs say their own, but nothing said
what happened to a click on the card itself. */}
{sync.error && (
<p role="alert" className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{attention > 0 && (
<Attention counts={sync.itemCounts} tripId={tripId} sync={sync} canManage={canManage} />
)}
</div>
) : (
<NothingBound canManage={canManage} />
)}
</div>
</div>
)}
</Modal>
{connecting && (
<DocSyncConnectModal
provider={connecting}
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
/>
)}
{reconnecting && (
<DocSyncConnectModal
provider={reconnecting}
sync={sync}
onClose={() => setReconnecting(null)}
onConnected={id => {
setReconnecting(null)
// Run straight away, as binding does: the card still reports the
// refusal until a run says otherwise, and the person has just
// done the one thing that could change the answer.
const rebound = sync.links.find(l => l.providerId === id)
if (rebound) void sync.syncNow(rebound.id)
}}
/>
)}
{scopeFor && sync.connectionFor(scopeFor) && (
<DocSyncScopeModal
connection={sync.connectionFor(scopeFor)!}
providerName={sync.providers.find(p => p.id === scopeFor)?.name ?? scopeFor}
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
/>
)}
</>
)
}
const ATTENTION_STATES = ['conflict', 'remote_missing', 'rejected_type', 'too_large', 'error'] as const
/** The stores: the ones this trip uses, then the ones it could. */
function Sidebar({
links,
providers,
available,
activeId,
canManage,
onSelect,
onAdd,
}: {
links: DocSyncLink[]
providers: DocSyncProvider[]
available: DocSyncProvider[]
activeId: number | null
canManage: boolean
onSelect: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
const { t } = useTranslation()
return (
<nav className="space-y-5">
{links.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{t('docsync.sidebar.connected')}
</h4>
<ul className="space-y-1.5">
{links.map(link => (
<li key={link.id}>
<StoreButton
providerId={link.providerId}
title={storeName(link, providers)}
subtitle={link.remoteLabel || link.remoteRootPath || link.scopeKey}
state={link.lastSyncState}
active={link.id === activeId}
onClick={() => onSelect(link.id)}
/>
</li>
))}
</ul>
</div>
)}
{canManage && available.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}
</h4>
<ul className="space-y-1.5">
{available.map(p => (
<li key={p.id}>
<StoreButton
providerId={p.id}
title={p.name}
subtitle={t(`docsync.model.${p.id}`)}
onClick={() => onAdd(p)}
addable
/>
</li>
))}
</ul>
</div>
)}
</nav>
)
}
/**
* One row in the store list.
*
* The selected row is marked with the accent on a subtle fill rather than a
* heavy border, so a list of five does not turn into five competing boxes.
*/
function StoreButton({
providerId,
title,
subtitle,
state,
active,
addable,
onClick,
}: {
providerId: string
title: string
subtitle: string
state?: string
active?: boolean
addable?: boolean
onClick: () => void
}) {
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
return (
<button
type="button"
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={[
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors',
active
? 'border-transparent bg-accent-subtle'
: 'border-edge bg-surface hover:bg-surface-hover',
].join(' ')}
>
<span
className={[
'grid h-8 w-8 shrink-0 place-items-center rounded-lg',
active ? 'bg-surface' : 'bg-surface-secondary',
].join(' ')}
>
{Icon ? <Icon className={`h-4 w-4 ${active ? 'text-accent-on' : 'text-content'}`} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-body ${active ? 'font-medium text-accent-on' : 'text-content'}`}>
{title}
</span>
<span className="mt-0.5 block truncate text-caption text-content-muted">{subtitle}</span>
</span>
{addable ? (
<Plus size={14} className="shrink-0 text-content-faint transition-transform group-hover:scale-110" />
) : state ? (
<StateBadge state={state} compact />
) : null}
</button>
)
}
/** What a person still has to decide about, named rather than counted in a chip. */
function Attention({
counts,
tripId,
sync,
canManage,
}: {
counts: Record<string, number>
tripId: number | string
sync: ReturnType<typeof useDocSync>
canManage: boolean
}) {
const { t } = useTranslation()
const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0)
const [showConflicts, setShowConflicts] = useState(false)
const conflicts = useConflicts(tripId, sync, showConflicts)
return (
<section className="rounded-xl border border-edge bg-surface">
<h4 className="flex items-center gap-2 border-b border-edge-faint px-4 py-2.5 text-body font-medium text-content">
<AlertCircle size={14} className="text-warning" />
{t('docsync.issues.title')}
</h4>
<ul className="divide-y divide-edge-faint">
{rows.map(k => (
<li key={k} className="px-4 py-2.5">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0">
<span className="block text-body text-content">{t(`docsync.state.${k}`)}</span>
<span className="mt-0.5 block text-caption text-content-muted">{t(`docsync.issues.${k}`)}</span>
</span>
{/* A conflict is the one of these a person can act on, so it is
the one that opens. The rest are a state to read. Only for
the owner: the server refuses a member's choice, so a member
gets the count, like every other row. */}
{k === 'conflict' && canManage ? (
<button
type="button"
onClick={() => setShowConflicts(v => !v)}
className="shrink-0 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
>
{showConflicts ? t('common.close') : t('docsync.conflict.resolve', { count: counts[k] })}
</button>
) : (
<span className="shrink-0 text-body font-medium tabular-nums text-content">{counts[k]}</span>
)}
</div>
{k === 'conflict' && canManage && showConflicts && (
<ul className="mt-3 space-y-2">
{conflicts.items === null && (
<li className="flex justify-center py-2">
<Loader2 size={14} className="animate-spin text-content-muted" />
</li>
)}
{conflicts.items?.map(item => (
<li key={item.id} className="rounded-lg bg-surface-secondary px-3 py-2.5">
<p className="truncate text-body text-content" title={item.name}>{item.name}</p>
<p className="mt-0.5 text-caption text-content-muted">{t('docsync.conflict.title')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(['trek', 'provider', 'both'] as const).map(keep => (
<button
key={keep}
type="button"
disabled={conflicts.working === item.id}
onClick={() => void conflicts.resolve(item.id, keep)}
className="rounded-lg border border-edge px-2.5 py-1 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t(`docsync.conflict.${keep === 'trek' ? 'keepTrek' : keep === 'provider' ? 'keepProvider' : 'keepBoth'}`)}
</button>
))}
</div>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)
}
/** The detail column before anything is bound: an invitation, not a blank. */
function NothingBound({ canManage }: { canManage: boolean }) {
const { t } = useTranslation()
return (
<div className="grid h-full min-h-[16rem] place-items-center rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<div>
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<ArrowRight size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.empty.title')}</p>
<p className="mx-auto mt-1 max-w-xs text-caption text-content-muted">
{canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
</p>
</div>
</div>
)
}
function Empty() {
const { t } = useTranslation()
return (
<div className="rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<Check size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mx-auto mt-1 max-w-sm text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
/**
* A folder name from the trip's own title, so nobody has to invent one.
*
* The id is appended because two trips can share a title and a folder cannot.
*/
function slugFor(title: string | undefined, tripId: number | string): string {
const base = (title || 'trek')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
return `${base || 'trek'}-${tripId}`
}
@@ -0,0 +1,383 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
/**
* The folder picker of the document-sync dialog.
*
* What matters to somebody opening it: the folders of their own store show up,
* the one they pick is the one that gets bound, making a new folder is one
* action rather than two, and a store that refuses to answer says so instead of
* pretending the archive is empty.
*
* The component takes the `useDocSync` hook as a prop, so the double here is the
* real hook with `api/client` mocked underneath, so the assertions are about
* the requests that actually leave, not about a hand-written stand-in. The hook
* is created with `enabled: false` because the panel-level lists it would fetch
* belong to the dialog around this modal, not to the picker.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncScope } from './useDocSync'
const S = en as unknown as Record<string, string>
const listScopes = vi.fn()
const createScope = vi.fn()
const createLink = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: () => Promise.resolve([]),
status: () => Promise.resolve({ items: {}, links: [] }),
listConnections: () => Promise.resolve([]),
listLinks: () => Promise.resolve([]),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopes(tripId, connectionId, q),
createScope: (tripId: number | string, connectionId: number, name: string) => createScope(tripId, connectionId, name),
createLink: (tripId: number | string, data: unknown) => createLink(tripId, data),
},
}
})
import DocSyncScopeModal from './DocSyncScopeModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const connection: DocSyncConnection = {
id: 5,
providerId: 'paperless',
baseUrl: 'https://docs.example.org',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
}
const folder = (n: number, label: string, remoteRootPath: string | null = `/Documents/${label}`): DocSyncScope =>
({ scopeKey: `folder-${n}`, label, remoteRootId: String(n), remoteRootPath })
/** Seven, so the list is one over the threshold that earns a search box. */
const SEVEN = [
folder(1, 'Trips'),
folder(2, 'Archive'),
folder(3, 'Receipts'),
folder(4, 'Invoices'),
folder(5, 'Photos'),
folder(6, 'Manuals'),
folder(7, 'Warranty'),
]
const SIX = SEVEN.slice(0, 6)
const THREE = SEVEN.slice(0, 3)
const onClose = vi.fn()
const onBound = vi.fn()
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
suggestedName?: string
providerName?: string
}) {
const sync = useDocSync(TRIP_ID, false)
return (
<DocSyncScopeModal
connection={connection}
providerName={providerName}
suggestedName={suggestedName}
sync={sync}
onClose={onClose}
onBound={onBound}
/>
)
}
const nameField = () => screen.getByPlaceholderText(S['docsync.newFolderPlaceholder'])
const createButton = () => screen.getByRole('button', { name: S['docsync.scope.createAction'] })
const searchField = () => screen.queryByPlaceholderText(S['docsync.scope.search'])
const rowFor = (label: string) => screen.getByText(label).closest('button') as HTMLButtonElement
beforeEach(() => {
vi.clearAllMocks()
listScopes.mockResolvedValue({ scopes: THREE })
createScope.mockResolvedValue(folder(9, 'Paris 2026'))
createLink.mockResolvedValue({ id: 1 })
})
describe('DocSyncScopeModal list', () => {
it('FE-DOCSYNC-SCOPE-001: asks the store for its folders as soon as it opens', async () => {
render(<Harness />)
await waitFor(() => expect(listScopes).toHaveBeenCalledWith(TRIP_ID, 5, undefined))
})
it('FE-DOCSYNC-SCOPE-002: shows every folder the store answered with, and where each one sits', async () => {
listScopes.mockResolvedValue({ scopes: THREE })
render(<Harness />)
expect(await screen.findByText('Trips')).toBeInTheDocument()
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.getByText('Receipts')).toBeInTheDocument()
expect(screen.getByText('/Documents/Archive')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-003: says nothing about an empty archive while the list is still in flight', async () => {
const pending = deferred<{ scopes: DocSyncScope[] }>()
listScopes.mockReturnValue(pending.promise)
render(<Harness />)
// The dangerous middle state: "no folders" is a claim about the store, and
// it must not be made before the store has answered.
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
pending.resolve({ scopes: THREE })
expect(await screen.findByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-004: a store with no folders at all says so', async () => {
listScopes.mockResolvedValue({ scopes: [] })
render(<Harness />)
expect(await screen.findByText(S['docsync.noFolders'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-005: names the store in the question it asks', async () => {
render(<Harness providerName="Paperless" />)
expect(await screen.findByText(S['docsync.scope.title'].replace('{provider}', 'Paperless'))).toBeInTheDocument()
expect(screen.getByText(S['docsync.scope.intro'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-006: the header close button leaves without binding anything', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(screen.getAllByRole('button')[0])
expect(onClose).toHaveBeenCalled()
expect(createLink).not.toHaveBeenCalled()
})
})
describe('DocSyncScopeModal search', () => {
it('FE-DOCSYNC-SCOPE-007: a list short enough to read gets no search box', async () => {
listScopes.mockResolvedValue({ scopes: SIX })
render(<Harness />)
await screen.findByText('Trips')
expect(searchField()).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-008: one folder past the threshold the search box appears', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
expect(searchField()).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-009: typing narrows the list without going back to the store', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
const before = listScopes.mock.calls.length
fireEvent.change(searchField()!, { target: { value: ' ARCH ' } })
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
// Matching is case- and whitespace-insensitive and costs no round trip.
expect(listScopes.mock.calls.length).toBe(before)
})
it('FE-DOCSYNC-SCOPE-010: a query nothing matches says so, and does not claim the store is empty', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
fireEvent.change(searchField()!, { target: { value: 'zzz' } })
expect(screen.getByText(S['docsync.scope.noMatch'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
})
describe('DocSyncScopeModal picking an existing folder', () => {
it('FE-DOCSYNC-SCOPE-011: binds the folder that was clicked, with the defaults a new binding gets', async () => {
render(<Harness />)
await screen.findByText('Archive')
fireEvent.click(rowFor('Archive'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, {
connectionId: 5,
scopeKey: 'folder-2',
remoteRootId: '2',
remoteRootPath: '/Documents/Archive',
remoteLabel: 'Archive',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
}))
})
it('FE-DOCSYNC-SCOPE-012: a bound folder closes the picker', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-013: a refused binding leaves the picker open so another folder can be tried', async () => {
createLink.mockRejectedValueOnce(Object.assign(new Error('nope'), {
response: { data: { error: 'forbidden' } },
}))
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(createLink).toHaveBeenCalled())
expect(onBound).not.toHaveBeenCalled()
expect(screen.getByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-014: while one folder is being bound nothing else can be started', async () => {
const pending = deferred<{ id: number }>()
createLink.mockReturnValueOnce(pending.promise)
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
expect(rowFor('Archive')).toBeDisabled()
expect(createButton()).toBeDisabled()
pending.resolve({ id: 1 })
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
})
describe('DocSyncScopeModal creating a folder', () => {
it('FE-DOCSYNC-SCOPE-015: the trip name is already in the field, so creating is one click', async () => {
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
expect(nameField()).toHaveValue('trek-paris-42')
expect(createButton()).toBeEnabled()
})
it('FE-DOCSYNC-SCOPE-016: creating makes the folder and binds it in one go', async () => {
createScope.mockResolvedValue(folder(9, 'Paris 2026', '/Documents/Paris 2026'))
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Paris 2026'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({
connectionId: 5,
scopeKey: 'folder-9',
remoteRootId: '9',
remoteLabel: 'Paris 2026',
})))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-021: a refused create says why instead of doing nothing', async () => {
// The create had no failure path at all: the rejection escaped the click
// handler unhandled, nothing was bound, and the dialog said nothing: the
// button blinked and the person was left guessing. Every other write in the
// hook reports, and so does this one now.
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(screen.getByText(S['docsync.error.quota_exceeded'])).toBeInTheDocument())
expect(createLink).not.toHaveBeenCalled()
expect(onBound).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-022: the create button comes back after a refusal', async () => {
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createButton()).toBeEnabled())
})
it('FE-DOCSYNC-SCOPE-017: a blank or whitespace-only name creates nothing', async () => {
render(<Harness suggestedName="" />)
await screen.findByText('Trips')
expect(createButton()).toBeDisabled()
fireEvent.change(nameField(), { target: { value: ' ' } })
expect(createButton()).toBeDisabled()
fireEvent.keyDown(nameField(), { key: 'Enter' })
expect(createScope).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-018: Enter in the name field creates the typed name, not the suggested one', async () => {
createScope.mockResolvedValue(folder(9, 'Belege'))
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
fireEvent.change(nameField(), { target: { value: ' Belege ' } })
fireEvent.keyDown(nameField(), { key: 'Enter' })
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Belege'))
})
})
describe('DocSyncScopeModal errors', () => {
it('FE-DOCSYNC-SCOPE-019: a store that refuses the credentials says so instead of showing an empty archive', async () => {
listScopes.mockResolvedValue({ scopes: [], error: 'unauthorized' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.unauthorized'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-020: an error wins over folders the store still managed to list', async () => {
listScopes.mockResolvedValue({ scopes: THREE, error: 'scope_missing' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-023: a listing request that fell over says so instead of spinning forever', async () => {
// The store answers 200 with a code when it refuses, but a request that
// never got an answer (a store behind a VPN that is down, a connection an
// admin deleted meanwhile) rejected instead, nothing caught it, and the
// spinner was all the picker ever showed.
listScopes.mockRejectedValue({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.unknown'])).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
// Making a new folder is still on offer: the store may well be reachable
// for that, and it is the way out when the listing is what failed.
expect(createButton()).toBeEnabled()
})
})
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync'
/**
* Picking the container a trip lives in.
*
* The list is filtered client-side: these are a person's own folders or tags,
* so there are tens of them, not thousands, and a round trip per keystroke
* would be slower than the filter it replaces.
*
* "Create" is a first-class action rather than a fallback at the bottom,
* because for a new trip it is the common case: there is no folder yet.
*/
export default function DocSyncScopeModal({
connection,
providerName,
suggestedName,
sync,
onClose,
onBound,
}: {
connection: DocSyncConnection
providerName: string
/** Pre-filled name for a new container, from the trip's title. */
suggestedName: string
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [newName, setNewName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(null)
// `loadScopes`, not `sync`: the hook hands back a fresh object on every
// render of the panel above, so depending on it re-listed the provider's
// folders each time anything up there changed. The callback itself is
// stable. Taken out of `sync` first, because calling it as `sync.loadScopes`
// inside the effect makes the whole object a dependency again.
const { loadScopes } = sync
useEffect(() => {
let cancelled = false
void (async () => {
const res = await loadScopes(connection.id)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connection.id, loadScopes])
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle || !scopes) return scopes ?? []
return scopes.filter(s => s.label.toLowerCase().includes(needle))
}, [scopes, query])
const bind = async (scope: DocSyncScope) => {
setWorking(scope.scopeKey)
const ok = await sync.createLink({
connectionId: connection.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setWorking(null)
if (ok) onBound()
}
const createAndBind = async () => {
const name = newName.trim()
if (!name) return
setWorking('__new__')
try {
// A refused create leaves nothing to bind; the hook has already put the
// reason where the dialog shows it.
const scope = await sync.createScope(connection.id, name)
if (scope) await bind(scope)
} finally {
setWorking(null)
}
}
return (
<Modal isOpen onClose={onClose} size="md" title={t('docsync.scope.title', { provider: providerName })}>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
<div className="flex gap-2">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void createAndBind() }}
placeholder={t('docsync.newFolderPlaceholder')}
className="min-w-0 flex-1 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content ring-accent focus:outline-none focus:ring-2"
/>
<button
type="button"
onClick={() => void createAndBind()}
disabled={!newName.trim() || working !== null}
className="flex shrink-0 items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
{t('docsync.scope.createAction')}
</button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('docsync.scope.pickTitle')}</span>
{scopes && scopes.length > 6 && (
<span className="relative">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('docsync.scope.search')}
className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2"
/>
</span>
)}
</div>
{sync.error && (
<p className="mb-2 rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{scopes === null ? (
<div className="grid place-items-center rounded-xl border border-edge py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
) : error ? (
<p className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${error}`)}
</p>
) : shown.length === 0 ? (
<p className="rounded-xl border border-dashed border-edge px-3 py-6 text-center text-caption text-content-muted">
{query ? t('docsync.scope.noMatch') : t('docsync.noFolders')}
</p>
) : (
<ul className="max-h-64 divide-y divide-edge-faint overflow-y-auto rounded-xl border border-edge">
{shown.map(s => (
<li key={s.scopeKey}>
<button
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<span className="min-w-0">
<span className="block truncate text-body text-content">{s.label}</span>
{s.remoteRootPath && (
<span className="block truncate text-caption text-content-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey && <Loader2 size={14} className="shrink-0 animate-spin text-content-faint" />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react'
import { docsyncApi } from '../../../api/client'
import type { useDocSync } from './useDocSync'
/**
* The documents that changed in both places, and the way out of that.
*
* The server has resolved conflicts since the first version and the strings
* have been in the locales just as long, but neither shell ever listed them: a
* binding reported "1 conflict" and the only place to act on it was the MCP
* tool. So a conflict was a dead end for anyone using the app.
*
* Shared between the panel and the phone sheet because both list the same rows
* with the same three answers, and because the duplication gate counts a second
* copy of it against the PR.
*/
export interface ConflictItem {
id: number
/** The trip file's name, or the provider's when TREK never got a copy. */
name: string
remoteName: string | null
}
interface ItemRow {
id: number
file_name?: string | null
remote_name?: string | null
}
export function useConflicts(tripId: number | string, sync: ReturnType<typeof useDocSync>, open: boolean) {
const [items, setItems] = useState<ConflictItem[] | null>(null)
const [working, setWorking] = useState<number | null>(null)
const reload = useCallback(async () => {
const rows = (await docsyncApi.items(tripId, 'conflict')) as ItemRow[]
setItems(
rows.map(r => ({
id: r.id,
name: r.file_name || r.remote_name || '',
remoteName: r.remote_name ?? null,
})),
)
}, [tripId])
// Only once the list is on screen: a trip with no conflicts should not pay
// for a request to find that out, and the count above it already says so.
useEffect(() => {
if (open) void reload()
}, [open, reload])
const resolve = useCallback(
async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
setWorking(itemId)
try {
await sync.resolveConflict(itemId, keep)
// The row is gone from this list either way: it was resolved, or the
// call failed and `sync.error` says so. Re-reading is what tells the
// difference, rather than removing it here and hoping.
await reload()
} finally {
setWorking(null)
}
},
[sync, reload],
)
return { items, working, resolve, reload }
}
@@ -0,0 +1,109 @@
import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
/**
* The credential form's rules, for both shells.
*
* Which field carries the address, which one must not be drawn as a text box,
* how a label becomes a translation key, what counts as filled in when a secret
* is already stored: every one of those is a rule, and every one of them was
* wrong in the phone sheet while being right on the desktop. The phone read the
* address under `baseUrl` while the server names the field `base_url`, so every
* connection it saved carried an empty address; it drew `allow_insecure_tls` as
* a text input and always sent false; and it built label keys a different way,
* so the labels rendered as raw keys.
*
* None of that is markup, so none of it belongs in a component.
*/
/** The field that holds the address rather than a credential. */
export const BASE_URL_FIELD = 'base_url'
/** Rendered as a switch beside the fields, never as one of them. */
export const INSECURE_TLS_FIELD = 'allow_insecure_tls'
export interface ConnectForm {
/** The fields to draw, in order, minus the ones with their own control. */
fields: DocSyncProvider['fields']
/** Translation key for a field's label, and for its hint where it has one. */
labelKey: (field: DocSyncProvider['fields'][number]) => string
hintKey: (field: DocSyncProvider['fields'][number]) => string | undefined
/** What to show in a field: the typed value, else what is stored. */
valueOf: (fieldKey: string) => string
/** A stored secret never comes back, so its box shows dots instead. */
placeholderOf: (field: DocSyncProvider['fields'][number]) => string
setValue: (fieldKey: string, value: string) => void
baseUrl: string
credentials: Record<string, string>
insecureTls: boolean
setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */
complete: boolean
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
save: () => Promise<boolean>
}
export function useConnectForm(
provider: DocSyncProvider,
existing: DocSyncConnection | null,
sync: ReturnType<typeof useDocSync>,
): ConnectForm {
const [values, setValues] = useState<Record<string, string>>({})
// Null until touched, so a connection that arrives after the form opened is
// still reflected. Seeding from `existing` at mount showed a stored "allow
// self-signed" as off and wrote that back on the next save.
const [insecure, setInsecure] = useState<boolean | null>(null)
const insecureTls = insecure ?? existing?.allowInsecureTls ?? false
/**
* What a box shows.
*
* A secret shows only what was typed into it this time, never what is stored:
* a stored credential must not travel back to a browser, and a value that
* ended up in `settings` by mistake must not become the one place it leaks.
* The address lives in its own column rather than in settings, so it is read
* from there; everything else comes out of settings.
*/
const secretKeys = new Set(provider.fields.filter(f => f.secret).map(f => f.field_key))
const valueOf = (key: string) => {
if (secretKeys.has(key)) return values[key] ?? ''
return values[key] ?? (key === BASE_URL_FIELD ? existing?.baseUrl : existing?.settings?.[key]) ?? ''
}
const baseUrl = valueOf(BASE_URL_FIELD)
// The address stays in here as well as in its own argument: the server walks
// the provider's fields over `credentials` and would otherwise store no
// `base_url` setting. It special-cases the key when checking completeness, so
// dropping it changes nothing it needs, but it is an established request
// shape and this is not the change to alter it in.
const credentials = Object.fromEntries(
Object.entries(values).filter(([k]) => k !== INSECURE_TLS_FIELD),
)
const fields = provider.fields.filter(f => f.field_key !== INSECURE_TLS_FIELD)
const complete = fields.every(f => {
if (!f.required) return true
if (f.secret) return (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key]
return valueOf(f.field_key).trim().length > 0
})
return {
fields,
// The label and hint columns store a key suffix, not text.
labelKey: f => `docsync.${f.label}`,
hintKey: f => (f.hint ? `docsync.${f.hint}` : undefined),
valueOf,
placeholderOf: f =>
f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || '',
setValue: (key, value) => setValues(v => ({ ...v, [key]: value })),
baseUrl,
credentials,
insecureTls,
setInsecureTls: setInsecure,
complete,
probe: () => sync.testConnection(provider.id, baseUrl, credentials, insecureTls),
save: () => sync.saveConnection(provider.id, baseUrl, credentials, insecureTls),
}
}
@@ -0,0 +1,517 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
/**
* The document-sync hook.
*
* Everything the panel and the phone sheet know about sync comes through here,
* so the interesting behaviour is what a person sees: holdings that arrive on a
* different route than the bindings they belong to, a dialog that must not flash
* white every time a switch is flipped, a toggle that moves before the server
* has answered, and a probe that has to produce a verdict even when the request
* itself fell over.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '../../../../tests/helpers/render'
const providersApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const statusApi = vi.fn(async (_tripId: number | string): Promise<unknown> => ({}))
const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinksApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({}))
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providersApi(tripId),
status: (tripId: number | string) => statusApi(tripId),
listConnections: (tripId: number | string) => listConnectionsApi(tripId),
listLinks: (tripId: number | string) => listLinksApi(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnectionApi(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
},
}
})
import { useDocSync, canManageDocSync, needsReauth, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
const TRIP = 3
const provider = (overrides: Record<string, unknown> = {}) => ({
id: 'nextcloud',
name: 'Nextcloud',
description: null,
icon: 'cloud',
available: true,
fields: [],
...overrides,
})
const connection = (overrides: Record<string, unknown> = {}) => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
const link = (overrides: Record<string, unknown> = {}) => ({
id: 1,
connectionId: 9,
providerId: 'nextcloud',
scopeKey: 'trip',
remoteLabel: 'Trip documents',
remoteRootPath: '/Trips/Rome',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'newer',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'idle',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** A promise this test settles by hand, to hold a request open. */
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Mount the hook and record `loading` on every render it goes through. */
function mount(tripId: number | string = TRIP, enabled = true) {
const seenLoading: boolean[] = []
const view = renderHook(() => {
const hook = useDocSync(tripId, enabled)
seenLoading.push(hook.loading)
return hook
})
return { ...view, seenLoading }
}
/** Mount and wait until the first load is through. */
async function mountLoaded(tripId: number | string = TRIP) {
const view = mount(tripId)
await waitFor(() => expect(view.result.current.loading).toBe(false))
return view
}
beforeEach(() => {
vi.clearAllMocks()
providersApi.mockResolvedValue([provider()])
statusApi.mockResolvedValue({ items: {}, links: [] })
listConnectionsApi.mockResolvedValue([connection()])
listLinksApi.mockResolvedValue([link()])
saveConnectionApi.mockResolvedValue({})
testConnectionApi.mockResolvedValue({ connected: true })
updateLinkApi.mockResolvedValue({})
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true })
})
describe('useDocSync initial load', () => {
it('FE-DOCSYNC-HOOK-001: joins the status route holdings onto the binding they belong to', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link()])
statusApi.mockResolvedValue({ items: { conflict: 2, pending: 1 }, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.itemCounts).toEqual({ conflict: 2, pending: 1 })
expect(result.current.connections).toEqual([connection()])
})
it('FE-DOCSYNC-HOOK-002: a binding the status route never mentions does not inherit another binding counts', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link(), link({ id: 2, remoteLabel: 'Receipts' })])
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.links[1].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-025: carries the provider switch from the status route onto its binding', async () => {
listLinksApi.mockResolvedValue([link(), link({ id: 2 }), link({ id: 3 })])
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, providerOff: true }, { id: 2, providerOff: false }] })
const { result } = await mountLoaded()
// The third is one the status route never mentions: running, not paused.
expect(result.current.links.map(l => l.providerOff)).toEqual([true, false, false])
})
it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => {
statusApi.mockResolvedValue({})
const { result } = await mountLoaded()
expect(result.current.itemCounts).toEqual({})
expect(result.current.links[0].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-004: offers only the providers this instance can actually use', async () => {
providersApi.mockResolvedValue([
provider(),
provider({ id: 'gdrive', name: 'Google Drive', available: false }),
])
const { result } = await mountLoaded()
expect(result.current.providers.map(p => p.id)).toEqual(['nextcloud'])
})
it('FE-DOCSYNC-HOOK-005: a failed load leaves nothing to configure instead of blowing up', async () => {
// The addon was switched off, or this member lost access to the trip.
providersApi.mockRejectedValue(Object.assign(new Error('forbidden'), { response: { status: 403 } }))
const { result } = await mountLoaded()
expect(result.current.providers).toEqual([])
expect(result.current.links).toEqual([])
expect(result.current.error).toBeNull()
expect(result.current.loading).toBe(false)
})
it('FE-DOCSYNC-HOOK-023: a reload that finds the addon gone drops the bindings it had loaded', async () => {
statusApi.mockResolvedValue({ items: { conflict: 1 }, links: [] })
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
providersApi.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } }))
await act(async () => { await result.current.load() })
expect(result.current.providers).toEqual([])
expect(result.current.connections).toEqual([])
expect(result.current.links).toEqual([])
expect(result.current.itemCounts).toEqual({})
expect(result.current.error).toBeNull()
})
it('FE-DOCSYNC-HOOK-006: asks the server nothing while the panel is closed', async () => {
const { result } = mount(TRIP, false)
await act(async () => { await Promise.resolve() })
expect(providersApi).not.toHaveBeenCalled()
expect(listLinksApi).not.toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync spinner', () => {
it('FE-DOCSYNC-HOOK-007: shows the spinner for the first load, which has nothing to show yet', async () => {
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
const { result, seenLoading } = mount()
expect(result.current.loading).toBe(true)
expect(seenLoading[seenLoading.length - 1]).toBe(true)
await act(async () => { gate.resolve([link()]) })
await waitFor(() => expect(result.current.loading).toBe(false))
})
it('FE-DOCSYNC-HOOK-008: never raises the spinner again while a mutation refetches', async () => {
// Regression guard: a refetch that flips `loading` swaps the whole dialog
// for a spinner and back, so flipping one switch flashes the panel white.
const { result, seenLoading } = await mountLoaded()
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
seenLoading.length = 0
let pending!: Promise<boolean>
await act(async () => { pending = result.current.updateLink(1, { syncEnabled: false }) })
expect(result.current.loading).toBe(false)
await act(async () => {
gate.resolve([link({ syncEnabled: false })])
await pending
})
// the refetch really happened...
expect(listLinksApi).toHaveBeenCalledTimes(2)
// ...and the panel never went blank for it
expect(seenLoading).not.toContain(true)
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync updateLink', () => {
it('FE-DOCSYNC-HOOK-009: moves the switch before the server has answered', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
updateLinkApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<boolean>
await act(async () => { pending = result.current.updateLink(1, { direction: 'pull' }) })
expect(result.current.links[0].direction).toBe('pull')
expect(updateLinkApi).toHaveBeenCalledWith(TRIP, 1, { direction: 'pull' })
await act(async () => { gate.resolve({}); await pending })
})
it('FE-DOCSYNC-HOOK-010: reloads afterwards, so the server row is what stays on screen', async () => {
const { result } = await mountLoaded()
listLinksApi.mockResolvedValue([link({ direction: 'pull', lastSyncState: 'ok', remoteLabel: 'Trip documents (moved)' })])
await act(async () => { await result.current.updateLink(1, { direction: 'pull' }) })
expect(listLinksApi).toHaveBeenCalledTimes(2)
expect(result.current.links[0].remoteLabel).toBe('Trip documents (moved)')
expect(result.current.links[0].lastSyncState).toBe('ok')
})
it('FE-DOCSYNC-HOOK-011: a rejected update does not leave the optimistic value standing', async () => {
const { result } = await mountLoaded()
updateLinkApi.mockRejectedValueOnce({ response: { data: { error: 'forbidden' } } })
listLinksApi.mockResolvedValueOnce([link({ direction: 'both' })])
let ok: boolean | undefined
await act(async () => { ok = await result.current.updateLink(1, { direction: 'pull' }) })
// Reported rather than thrown: both shells call this as `void`, so a
// rejection reached nobody. The reload puts the server's answer back and
// the reason stays on screen.
expect(ok).toBe(false)
expect(result.current.links[0].direction).toBe('both')
expect(result.current.error).toBe('forbidden')
})
})
describe('useDocSync testConnection', () => {
it('FE-DOCSYNC-HOOK-012: hands the probe verdict back to the form', async () => {
testConnectionApi.mockResolvedValue({ connected: true, account: 'owner@cloud.example' })
const { result } = await mountLoaded()
let verdict: { connected: boolean; account?: string; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', { token: 'abc' }, false)
})
expect(verdict).toEqual({ connected: true, account: 'owner@cloud.example' })
expect(testConnectionApi).toHaveBeenCalledWith(TRIP, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
credentials: { token: 'abc' },
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-HOOK-013: a request that throws still produces a verdict the form can render', async () => {
testConnectionApi.mockRejectedValueOnce({ response: { data: { error: 'docsync.unreachable' } } })
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://typo.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'docsync.unreachable' })
})
it('FE-DOCSYNC-HOOK-014: an error with no message of its own falls back to a generic one', async () => {
testConnectionApi.mockRejectedValueOnce(new Error('socket hang up'))
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'unknown' })
})
it('FE-DOCSYNC-HOOK-015: marks the probe busy while it runs and clears it when it lands', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
testConnectionApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<unknown>
await act(async () => { pending = result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) })
expect(result.current.busy).toBe('test')
await act(async () => { gate.resolve({ connected: true }); await pending })
expect(result.current.busy).toBeNull()
})
})
describe('useDocSync run and save feedback', () => {
it('FE-DOCSYNC-HOOK-016: remembers what the last run moved, per binding', async () => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 4, pushed: 2, conflicts: 0, missing: 0 })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1) })
expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, false)
// What a run moved is no longer kept: the flow bar shows standing holdings,
// which is the question somebody opening the dialog actually has. The run's
// own numbers were only ever on screen for the moment after pressing it.
expect(result.current.links).toHaveLength(1)
})
it('FE-DOCSYNC-HOOK-017: a refused run says so instead of vanishing into a void call', async () => {
// syncNow is called as `void sync.syncNow(...)` from both shells, so a
// rejection that escaped the hook reached nobody. An orphaned binding
// answers 409 here, and the person pressing the button deserves the reason.
syncNowApi.mockRejectedValueOnce({ response: { data: { error: 'link_orphaned' } } })
const { result } = await mountLoaded()
let outcome: unknown
await act(async () => { outcome = await result.current.syncNow(1) })
expect(outcome).toBeNull()
expect(result.current.error).toBe('link_orphaned')
})
it('FE-DOCSYNC-HOOK-026: a run refused because an admin switched the provider off keeps the code', async () => {
// Both shells render the error as docsync.error.<code>, so the code has to
// arrive untouched for the sentence to be the right one.
syncNowApi.mockRejectedValueOnce({ response: { status: 409, data: { error: 'provider_disabled' } } })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1) })
expect(result.current.error).toBe('provider_disabled')
})
it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => {
saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.saveConnection('nextcloud', 'https://cloud.example', { token: 'nope' }, false)
})
expect(ok).toBe(false)
expect(result.current.error).toBe('docsync.badCredentials')
expect(result.current.busy).toBeNull()
// nothing was refetched, because nothing changed server-side
expect(listLinksApi).toHaveBeenCalledTimes(1)
})
})
describe('useDocSync loadScopes', () => {
it('FE-DOCSYNC-HOOK-027: a request that fell over still produces a listing the picker can render', async () => {
// The picker awaits this inside an effect with nothing to catch, so a
// rejection never reached `setScopes` and the spinner ran forever. A store
// behind a VPN that is down answers this way every time.
listScopesApi.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
const { result } = await mountLoaded()
let listing: unknown
await act(async () => { listing = await result.current.loadScopes(9) })
expect(listing).toEqual({ scopes: [], error: 'unknown' })
})
it('FE-DOCSYNC-HOOK-028: a refusal with a code keeps the code, a sentence does not become a key', async () => {
const { result } = await mountLoaded()
let listing: { scopes: unknown[]; error?: string } | undefined
listScopesApi.mockRejectedValueOnce({ response: { status: 400, data: { error: 'unknown_provider' } } })
await act(async () => { listing = await result.current.loadScopes(9, 'tri') })
expect(listing?.error).toBe('unknown_provider')
expect(listScopesApi).toHaveBeenCalledWith(TRIP, 9, 'tri')
// The picker renders this as docsync.error.<value>, so prose must not
// arrive there as a translation key.
listScopesApi.mockRejectedValueOnce({ response: { status: 404, data: { message: 'Connection not found' } } })
await act(async () => { listing = await result.current.loadScopes(9) })
expect(listing?.error).toBe('unknown')
})
})
describe('useDocSync resolveConflict', () => {
it('FE-DOCSYNC-HOOK-029: a refused choice says why instead of vanishing into a void call', async () => {
// Both shells call this as `void conflicts.resolve(...)`. A member's choice
// answers 403 and a row settled elsewhere answers 400; either way the
// button came back and nothing said what happened.
resolveApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let outcome: boolean | undefined
await act(async () => { outcome = await result.current.resolveConflict(4, 'trek') })
expect(outcome).toBe(false)
expect(result.current.error).toBe('forbidden')
// reloaded, so the counts are the server's, and the message survived it
expect(listLinksApi).toHaveBeenCalledTimes(2)
await act(async () => { outcome = await result.current.resolveConflict(4, 'provider') })
expect(outcome).toBe(true)
expect(result.current.error).toBeNull()
expect(resolveApi).toHaveBeenLastCalledWith(TRIP, 4, 'provider')
})
})
describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })).toBe(true)
expect(needsReauth({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })).toBe(true)
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unreachable' })).toBe(false)
expect(needsReauth({ lastSyncState: 'ok', lastSyncError: null })).toBe(false)
})
})
describe('storeName', () => {
it('FE-DOCSYNC-HOOK-024: prefers the provider list, then the name on the link, then the raw id', () => {
const bound = link() as DocSyncLink
const listed = [provider({ name: 'Nextcloud Hub' }) as DocSyncProvider]
expect(storeName({ ...bound, providerName: 'Nextcloud' }, listed)).toBe('Nextcloud Hub')
// Switched off by an admin, so the providers route no longer lists it.
expect(storeName({ ...bound, providerName: 'Nextcloud' }, [])).toBe('Nextcloud')
expect(storeName(bound, [])).toBe('nextcloud')
})
})
describe('canManageDocSync', () => {
it('FE-DOCSYNC-HOOK-019: the trip owner may repoint a binding', () => {
expect(canManageDocSync({ id: 4, role: 'user' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-020: an instance admin may, even on a trip that is not theirs', () => {
expect(canManageDocSync({ id: 12, role: 'admin' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-021: an ordinary member may not point the owner archive somewhere else', () => {
expect(canManageDocSync({ id: 12, role: 'user' }, { user_id: 4 })).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, null)).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, {})).toBe(false)
})
it('FE-DOCSYNC-HOOK-022: nobody signed in, nobody may manage', () => {
expect(canManageDocSync(null, { user_id: 4 })).toBe(false)
expect(canManageDocSync(undefined, { user_id: 4 })).toBe(false)
// ids arrive as strings on some routes and as numbers on others
expect(canManageDocSync({ id: '4', role: 'user' }, { user_id: 4 })).toBe(true)
})
})
@@ -0,0 +1,449 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { docsyncApi } from '../../../api/client'
import { useServerPing } from '../../../sync/useServerPing'
/**
* All document-sync state and actions, in one hook.
*
* Deliberately a hook rather than logic inside the panel: the phone shell is a
* separate component tree (client/src/mobile/screens/trip/...), and duplicating
* this across both would land on SonarCloud's 3% duplication budget the way the
* photo provider section already does. `useDawarichConnection` states the same
* reason for the same problem.
*/
export interface DocSyncField {
field_key: string
label: string
input_type: string
placeholder: string | null
hint: string | null
required: boolean
secret: boolean
}
export interface DocSyncProvider {
id: string
name: string
description: string | null
icon: string
available: boolean
fields: DocSyncField[]
}
export interface DocSyncConnection {
id: number
providerId: string
baseUrl: string
settings: Record<string, string>
secrets: Record<string, string>
allowInsecureTls: boolean
lastProbeState: string
lastProbeError: string | null
}
export interface DocSyncScope {
scopeKey: string
label: string
remoteRootId: string | null
remoteRootPath: string | null
}
export interface DocSyncLink {
id: number
connectionId: number
providerId: string
scopeKey: string
remoteLabel: string
remoteRootPath: string | null
direction: 'both' | 'pull' | 'push'
deletePolicy: 'unlink' | 'trash'
conflictPolicy: string
syncEnabled: boolean
lastSyncAt: string | null
lastSyncState: string
lastSyncError: string | null
webhookUrl: string | null
/**
* The store's display name as the server knows it. Carried on the link
* because the providers route only lists what an admin has switched on, and
* a binding outlives its provider being switched off.
*/
providerName?: string
/** Standing counts per side, from the status route. */
holdings?: { inTrek: number; atProvider: number; paired: number; missing: number }
/**
* An admin has switched this binding's provider off. The server leaves the
* binding as it was, so `lastSyncState` still describes the last run and
* this is what says why nothing moves now. From the status route.
*/
providerOff?: boolean
}
/**
* What to call the store a binding points at.
*
* One lookup for both shells, so the list row, the card header and the flow
* summary cannot name the same store three different ways. The providers list
* wins because the rest of the dialog uses its names; the link's own name
* covers a provider that has since been switched off.
*/
export function storeName(link: DocSyncLink, providers: readonly DocSyncProvider[]): string {
return providers.find(p => p.id === link.providerId)?.name || link.providerName || link.providerId
}
/**
* The line a binding card shows under its header, or null when there is
* nothing to say.
*
* Both shells render it, so a phone and a laptop cannot disagree about why a
* binding stands still. A provider an admin switched off comes first: the
* server leaves the binding's state as the last run wrote it, which would
* otherwise report an old failure, or nothing, for a binding that is paused.
*/
export function bindingNotice(
link: Pick<DocSyncLink, 'providerOff' | 'lastSyncState' | 'lastSyncError'>,
t: (key: string) => string,
): string | null {
if (link.providerOff) return t('docsync.error.provider_disabled')
if (link.lastSyncState === 'ok' || link.lastSyncState === 'never') return null
const state = t(`docsync.linkState.${link.lastSyncState}`)
return link.lastSyncError ? `${state} · ${t(`docsync.error.${link.lastSyncError}`)}` : state
}
/**
* Whether the store has refused the stored credential.
*
* Two shapes say so. A refusal while listing is recorded as its own state; a
* refusal anywhere else (resolving the folder, one transfer) leaves the state
* at failed or partial and puts the reason in the error code. Either way the
* cure is the same, a new credential, and both shells decide from here
* whether to offer the form for it.
*/
export function needsReauth(link: Pick<DocSyncLink, 'lastSyncState' | 'lastSyncError'>): boolean {
return link.lastSyncState === 'needs_reauth' || link.lastSyncError === 'unauthorized'
}
/**
* Whether this person may change a sync binding.
*
* Only the trip owner: the credential a binding stores usually reaches that
* person's entire document archive, so letting any member repoint it would
* share a folder the owner never chose to share. Instance admins are included
* because they already override every other permission check in the client.
*
* Shared by both shells rather than written out twice: the desktop file
* manager and the phone sheet must not be able to drift on who may do this.
*/
export function canManageDocSync(
user: { id?: number | string; role?: string } | null | undefined,
trip: { user_id?: number | string } | null | undefined,
): boolean {
if (!user) return false
return user.role === 'admin' || Number(trip?.user_id) === Number(user.id)
}
export function useDocSync(tripId: number | string, enabled: boolean) {
const [providers, setProviders] = useState<DocSyncProvider[]>([])
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
/**
* Whether the first load is through.
*
* Every mutation refreshes, and a refresh that flips `loading` swaps the whole
* dialog for a spinner and back: a white flash on something as small as
* toggling a direction. Only the first load has nothing to show yet.
*/
const loadedOnce = useRef(false)
/**
* Which load is the current one.
*
* Four requests go out per load and several loads overlap: every mutation
* starts one, and so does every ping. Without a generation stamp the slowest
* response wins, which on a binding somebody just changed means the screen
* settles on the state from before the change.
*/
const generation = useRef(0)
/** Whether the last failure came from loading rather than from a write. */
const loadFailed = useRef(false)
const load = useCallback(async () => {
if (!enabled) return
const mine = ++generation.current
if (!loadedOnce.current) setLoading(true)
try {
const [p, c, l, s] = await Promise.all([
docsyncApi.providers(tripId),
docsyncApi.listConnections(tripId),
docsyncApi.listLinks(tripId),
docsyncApi.status(tripId),
])
if (mine !== generation.current) return
setProviders(p as DocSyncProvider[])
setConnections(c as DocSyncConnection[])
// The status route carries the holdings and the provider switch; the
// links route does not, so the two are merged here rather than asking
// every caller to join them.
const status = s as { items?: Record<string, number>; links?: DocSyncLink[] }
const statusById = new Map((status.links ?? []).map(x => [x.id, x]))
setLinks((l as DocSyncLink[]).map(x => ({
...x,
holdings: statusById.get(x.id)?.holdings,
providerOff: statusById.get(x.id)?.providerOff === true,
})))
setItemCounts(status.items || {})
// Only a load failure is cleared here. A write that just failed reloads to
// put the server's answer back on screen, and clearing unconditionally
// wiped the reason it had set a moment earlier.
setError(prev => (prev !== null && loadFailed.current ? null : prev))
loadFailed.current = false
loadedOnce.current = true
} catch (e: unknown) {
// A 403 or 404 really does mean "nothing to configure here" (the addon is
// off or this person lost access), and blanking the panel is the honest
// answer. Anything else is a failure to say so: blanking on a dropped
// connection told the user their provider list was empty.
if (mine !== generation.current) return
const status = (e as { response?: { status?: number } })?.response?.status
loadFailed.current = true
if (status === 403 || status === 404) {
// The bindings go too. Both shells keep a binding on screen with no
// provider left, so emptying the providers alone blanked nothing.
setProviders([])
setConnections([])
setLinks([])
setItemCounts({})
setError(null)
} else {
setError(readError(e))
}
} finally {
if (mine === generation.current) setLoading(false)
}
}, [tripId, enabled])
useEffect(() => { void load() }, [load])
// A run started by another member moves the same documents and the same
// counts. The event is a ping rather than a payload: the numbers it would
// carry are per-binding server state, and reading them back is the only way
// this panel and that member's panel end up saying the same thing.
useServerPing('docsync:changed', enabled, load)
const connectionFor = useCallback(
(providerId: string) => connections.find(c => c.providerId === providerId) || null,
[connections],
)
const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('save')
setError(null)
try {
await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls })
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo
* needs a field to render, not an exception.
*/
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) {
return { connected: false, error: readError(e) }
} finally {
setBusy(null)
}
}, [tripId])
/**
* The containers a connection can be bound to.
*
* Always resolves to a listing, the way `testConnection` always resolves to
* a verdict: the route answers 200 with an error code when the store refuses,
* and a request that fell over on the way (a store behind a VPN that is not
* up, a connection an admin deleted meanwhile) has to reach the picker the
* same way. Left to reject, it never reached `setScopes`, and the picker
* spun forever with nothing said.
*/
const loadScopes = useCallback(async (connectionId: number, q?: string) => {
try {
return await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
} catch (e: unknown) {
return { scopes: [] as DocSyncScope[], error: readError(e) }
}
}, [tripId])
/**
* Make a container at the provider.
*
* Reports failure the same way every other write here does. A rejected
* create used to escape as an unhandled rejection, so the button blinked and
* nothing happened and nothing was said. Null rather than a throw, because
* the caller's next step is to bind what came back, and there is nothing to
* bind.
*/
const createScope = useCallback(async (connectionId: number, name: string): Promise<DocSyncScope | null> => {
setBusy('scope')
setError(null)
try {
return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope
} catch (e: unknown) {
setError(readError(e))
return null
} finally {
setBusy(null)
}
}, [tripId])
const createLink = useCallback(async (payload: Record<string, unknown>) => {
setBusy('link')
setError(null)
try {
await docsyncApi.createLink(tripId, payload)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Change one binding, optimistically.
*
* The reload in `finally` puts the server's answer back either way, so a
* refused change corrects itself on screen, but it used to do that in
* silence, with the rejection escaping into a `void` call site. The switch
* simply flipped back and nobody said why.
*/
const updateLink = useCallback(async (linkId: number, patch: Record<string, unknown>) => {
setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l)))
setError(null)
try {
await docsyncApi.updateLink(tripId, linkId, patch)
await load()
return true
} catch (e: unknown) {
// Reload first, so the optimistic value is corrected, then say why.
// The other way round the reload cleared the message again.
await load()
setError(readError(e))
return false
}
}, [tripId, load])
const removeLink = useCallback(async (linkId: number) => {
setBusy('link')
setError(null)
try {
await docsyncApi.deleteLink(tripId, linkId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
const syncNow = useCallback(async (linkId: number, full = false) => {
setBusy(`sync-${linkId}`)
setError(null)
try {
const res = await docsyncApi.syncNow(tripId, linkId, full) as {
state: string; pulled: number; pushed: number; conflicts: number; missing: number
}
await load()
return res
} catch (e: unknown) {
// A refused run is a thing the person asked for and did not get (an
// orphaned binding answers 409 here), so it has to be said out loud
// rather than escaping into the `void` at the call site. After the
// reload, which would otherwise clear the message.
await load()
setError(readError(e))
return null
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Settle one conflict.
*
* Reports failure the way the other writes do. The rejection used to escape
* into the `void` at the call site, so a refused choice (the row was settled
* elsewhere a moment ago, or the server answered 403) put the button back
* and left the row standing with nothing said.
*/
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
setError(null)
try {
await docsyncApi.resolve(tripId, itemId, keep)
await load()
return true
} catch (e: unknown) {
// Reload first, so the counts match what the server has, then say why.
// The other way round the reload cleared the message again.
await load()
setError(readError(e))
return false
}
}, [tripId, load])
/** Providers the admin switched on that also have a working adapter. */
const usableProviders = useMemo(() => providers.filter(p => p.available), [providers])
return {
providers: usableProviders,
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
}
/**
* The error code a failed call carries, or `unknown`.
*
* Both shells render this as `docsync.error.<code>`, so anything that is not
* one of the codes reaches the screen as a raw translation key, and the server
* answers plenty of routes with a sentence ("Link not found") rather than a
* code. A general message is worse than a specific one and far better than
* `docsync.error.Link not found`.
*/
function readError(e: unknown): string {
const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response
const raw = res?.data?.error || res?.data?.message || ''
// A code passes through: the sync codes and the handful of keys the routes
// answer with are all of this shape. A sentence does not: several routes
// answer with prose ("Link not found"), and both shells render this as
// `docsync.error.<value>`, so that reached the screen as a raw translation
// key. A general message is worse than a specific one and far better than
// `docsync.error.Link not found`.
return /^[a-z0-9_.]+$/i.test(raw) ? raw : 'unknown'
}
@@ -0,0 +1,190 @@
// FE-DOCSYNC-OFFER-001 to FE-DOCSYNC-OFFER-013
/**
* Whether the Files screen offers document sync.
*
* The providers ship switched off, so the rule is what keeps an upgraded
* instance from growing a button into an empty dialog on every trip: a bound
* trip shows it to everybody, an unbound one only to somebody who could bind
* it, and only once the admin has switched a provider on.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { act, renderHook, waitFor } from '../../../../tests/helpers/render'
import { server } from '../../../../tests/helpers/msw/server'
import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore'
import { setForcedOffline } from '../../../sync/networkMode'
import { useDocSyncOffered } from './useDocSyncOffered'
const listeners = new Set<(e: Record<string, unknown>) => void>()
vi.mock('../../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => void) => { listeners.delete(fn) },
}))
/** Deliver an event the way the websocket layer would. */
function emit(type: string): void {
for (const fn of [...listeners]) fn({ type })
}
const TRIP = 4
let providerCalls = 0
let linkCalls = 0
function routes({ providers = [] as unknown[], links = [] as unknown[], linksStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => {
providerCalls += 1
return HttpResponse.json(providers)
}),
http.get('/api/trips/:tripId/docsync/links', () => {
linkCalls += 1
return linksStatus === 200
? HttpResponse.json(links)
: HttpResponse.json({ error: 'nope' }, { status: linksStatus })
}),
)
}
const paperless = { id: 'paperless', name: 'Paperless-ngx', available: true }
const binding = { id: 1, providerId: 'paperless' }
beforeEach(() => {
providerCalls = 0
linkCalls = 0
listeners.clear()
useDocSyncOfferStore.setState({ bound: {}, providers: null })
})
afterEach(() => setForcedOffline(false))
describe('useDocSyncOffered', () => {
it('FE-DOCSYNC-OFFER-001: somebody who may bind the trip sees it once a provider is on, without asking about bindings', async () => {
routes({ providers: [paperless] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
expect(providerCalls).toBe(1)
expect(linkCalls).toBe(0)
})
it('FE-DOCSYNC-OFFER-002: with every provider off and nothing bound, nobody sees it', async () => {
routes({ providers: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-003: a provider row without an adapter does not count as offered', async () => {
routes({ providers: [{ ...paperless, available: false }] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-004: a member sees a bound trip, and is never asked about providers', async () => {
routes({ providers: [paperless], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(result.current).toBe(true))
expect(providerCalls).toBe(0)
})
it('FE-DOCSYNC-OFFER-005: a member does not see an unbound trip, providers or not', async () => {
routes({ providers: [paperless], links: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-006: a bound trip keeps the button after the admin switched every provider off', async () => {
routes({ providers: [], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
})
it('FE-DOCSYNC-OFFER-007: the last answer renders at once on the next visit, then is checked again', async () => {
routes({ links: [binding] })
const first = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(first.result.current).toBe(true))
first.unmount()
const second = renderHook(() => useDocSyncOffered(TRIP, false))
expect(second.result.current).toBe(true)
await waitFor(() => expect(linkCalls).toBe(2))
})
it('FE-DOCSYNC-OFFER-008: offline it asks nothing and keeps what it knew, and asks again once back', async () => {
routes({ links: [] })
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
act(() => setForcedOffline(true))
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
expect(result.current).toBe(true)
expect(linkCalls).toBe(0)
act(() => setForcedOffline(false))
await waitFor(() => expect(result.current).toBe(false))
expect(linkCalls).toBe(1)
})
it('FE-DOCSYNC-OFFER-009: a request that never got an answer leaves the last one standing', async () => {
server.use(http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.error()))
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await act(() => useDocSyncOfferStore.getState().refresh(TRIP, false))
expect(result.current).toBe(true)
})
it('FE-DOCSYNC-OFFER-010: the Documents addon switched off is a real no', async () => {
routes({ linksStatus: 403 })
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(result.current).toBe(false))
})
it('FE-DOCSYNC-OFFER-011: a refresh asked for while offline sends nothing and changes nothing', async () => {
routes({ providers: [paperless] })
useDocSyncOfferStore.setState({ providers: false })
setForcedOffline(true)
await useDocSyncOfferStore.getState().refresh(TRIP, true)
expect(providerCalls).toBe(0)
expect(useDocSyncOfferStore.getState().providers).toBe(false)
})
it('FE-DOCSYNC-OFFER-012: a member on the Files tab gets the button when the owner binds the trip', async () => {
routes({ links: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
// The owner binds it: nothing remounts here, the server only pings.
routes({ links: [binding] })
act(() => emit('docsync:changed'))
await waitFor(() => expect(result.current).toBe(true))
expect(linkCalls).toBe(2)
})
it('FE-DOCSYNC-OFFER-013: the button goes when the last binding does, with every provider off', async () => {
routes({ providers: [], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
routes({ providers: [], links: [] })
act(() => emit('docsync:changed'))
await waitFor(() => expect(result.current).toBe(false))
})
})

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