Compare commits

...

215 Commits

Author SHA1 Message Date
github-actions[bot] 151e32107f chore: bump version to 4.3.2 [skip ci] 2026-09-24 11:14:02 +00:00
Maurice 8779529efb feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.

Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
2026-09-24 11:32:52 +02:00
Maurice e1315806c2 feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.

It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.

The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.

The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
2026-09-24 11:32:52 +02:00
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00
Maurice 3062df153f chore(notices): the release notice points at 4.3.2 2026-09-24 11:27:12 +02:00
Maurice 233076030b fix(docsync): a store picked before the panel settles stays picked
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
2026-09-24 08:14:29 +02:00
Maurice c5ac479915 fix(dayplan): sort the trip's dates with an explicit compare
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
2026-09-23 23:11:10 +02:00
Maurice 954758082b fix(dayplan): calmer day dialogs, and the shrink question side by side
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
2026-09-23 22:40:47 +02:00
Maurice 83be759c49 fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
2026-09-23 22:40:47 +02:00
Maurice f3ca0b1a4c test(mobile): the delete-day confirm test takes a free id
FE-MOB-SHOST-030 is taken by the invite field test that landed on dev in
the meantime, so the delete-day confirm test moves to 032.
2026-09-23 22:40:47 +02:00
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00
Maurice 1639fc6a3e docs(wiki): deleting and adding days, and what shortening a trip removes
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
2026-09-23 22:40:46 +02:00
Maurice 1b63285ad0 feat(trips): shortening a trip says first which days go and what is on them
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
2026-09-23 22:40:46 +02:00
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00
Maurice 436f0740fb fix(planner): the delete question says plainly what goes with a booked place
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
2026-09-23 22:39:54 +02:00
Maurice 1423ba8933 fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
2026-09-23 22:35:01 +02:00
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00
Maurice 362b1ee8d1 fix(map): a selected stay is flown to even when no pin stands for it
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
2026-09-23 22:11:51 +02:00
Maurice b5457d9e6a fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
2026-09-23 21:28:34 +02:00
Maurice a1d6348c5f revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
2026-09-23 20:32:41 +02:00
Maurice f16698bdb1 fix: an Immich error leaves out Node's advice, a locked save looks locked
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
2026-09-23 18:03:58 +02:00
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00
Maurice 0df82185bd feat(collections): a saved place keeps its price, website and phone, editable like in a trip
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
2026-09-23 17:41:26 +02:00
Maurice e8ec3427e5 fix(share): the user picked to invite shows in the field before it is sent
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
2026-09-23 17:41:26 +02:00
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00
Maurice 41b14ebc2a fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
2026-09-23 17:41:25 +02:00
Maurice 05f533dd0d fix(roadtrip): a late check-out morning says so, a road driven twice takes its via
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
2026-09-23 16:01:40 +02:00
Maurice e6e520b78f fix(roadtrip): the hotel at the edge of a day stands, drives and reads right
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
2026-09-23 15:31:09 +02:00
Maurice 05651eed1a docs(wiki): a road trip day can start and end at the stay
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
2026-09-23 15:31:09 +02:00
Maurice 7e7d13c021 feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
2026-09-23 15:31:09 +02:00
Maurice 514e2e62ee feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
2026-09-23 15:31:09 +02:00
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00
Maurice 3a67a8e279 fix(planner): trip places in every manual location field, never at 0,0
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
2026-09-23 13:41:31 +02:00
hiro-nikaitou cc5957dc6a fix(planner): offer trip places in manual transport
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
2026-09-23 13:34:02 +02:00
Maurice 83d088e842 fix(roadtrip): a merged drive keeps its first piece ends when a leg comes without them
A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
2026-09-23 12:53:24 +02:00
Maurice e921799580 test(roadtrip): mount the phone case of a refused way the way the phone suites do
The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
2026-09-23 12:30:10 +02:00
Maurice 34c1dc79b5 fix(roadtrip): a drive bent through a via prints its whole length, and a refusal is said once
A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.

On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
2026-09-23 12:03:00 +02:00
Maurice 451e6d240b fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken
The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
2026-09-23 11:28:24 +02:00
Maurice ab3a81a9eb fix(roadtrip): a booked ferry is driven to, and one without a date says so
A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
2026-09-23 11:28:24 +02:00
Maurice e6daed9403 fix(roadtrip): the drive between two days can take another way too
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
2026-09-23 11:28:24 +02:00
Maurice 53a422d22d fix(roadtrip): other ways come from the router that draws the leg, and a choice is saved once it holds
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.

The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
2026-09-23 11:28:24 +02:00
Maurice 6124e0b056 fix(maps): amap hours are read whole or not at all, and the tip stash lives on the service
The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.

amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.

The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
2026-09-23 09:36:04 +02:00
pipipiper d52154c765 fix(maps): amap answers for tip-only places, region-bearing fallback queries, and Chinese hours 2026-09-23 09:36:04 +02:00
Maurice aceb75949b fix(planner): closing the place dialog resets the whole search block
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.

Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
2026-09-23 09:11:48 +02:00
Luffy 4f5a6bb7d8 fix(planner): clear place search input when modal closes 2026-09-23 09:07:48 +02:00
Maurice d609e1e202 Merge pull request #2466 from liketrek/docs/wiki-mcp-openwebui-callback
docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
2026-09-23 08:49:56 +02:00
Maurice 6f3977e4c1 docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
The MCP setup page gave Open WebUI's redirect URI as
{your Open WebUI URL}/oauth/clients/{connection id}/callback. Open WebUI puts
mcp: in front of the ID it builds that path from, so a client registered by
hand with the bare ID never matched and the consent step answered
"Unregistered redirect_uri". The page now names the real path and adds how
Open WebUI connects: it registers and trades the code from its own server, so
its container has to reach the public TREK address, and its WebUI URL has to
be the public https address.
2026-09-23 08:28:29 +02:00
Maurice 615e74aff6 fix(pwa): an upgrade counts as applied once the new build has taken over
After a server upgrade the app wrote the new version into its marker
before the service worker had fetched the new build. When that fetch
failed, when the new precache broke off halfway, or when the browser had
already swapped workers before the version check answered, the page kept
the old bundle, and every later launch took the marker at its word and
never tried again. An installed PWA went on showing the previous UI.

The marker now moves only once the handover is done or turned out to be
unnecessary, so a failed attempt is simply retried on the next launch. A
failure no longer reloads into the shell the old worker serves again, a
worker that took over before the check answered still gets its reload,
and no path reloads more than once per version and session. Caches and
workers are still never deleted.
2026-09-22 21:48:23 +02:00
Maurice bb515ac3cc fix(planner): booking codes stay blurred in the edit forms too
With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.

One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
2026-09-22 21:48:22 +02:00
Maurice 93dddaa4af fix(mobile): booking routes on the phone map follow the day they run on
A booking switched on with "On map" stayed drawn on every day of the
phone's plan map, so a flight from the first day crossed the map of the
third, although a day chip frames one day and drops the other days' pins.
The plan map now draws such a booking only on the days it runs on, the
arrival day of an overnight one included, and the all-days view still
shows every booking that is switched on. The switch itself stays
trip-wide, and the desktop, collections and the road trip stage draw
exactly as before.

"On map" in a booking's sheet now reads as on only where the plan map
draws the route. From any other day a tap takes the map to the booking's
day, switching the route on when it is off, instead of hiding a route
that is not drawn there anyway.

The phone settings also gain the "Always show booking routes" switch the
desktop already had. As on the desktop it sets the default for trips not
yet touched on this device, so a trip where a route was switched on or
off by hand keeps that choice.
2026-09-22 21:48:22 +02:00
Maurice 1a8d5a3dd9 fix(roadtrip): a stop with a start is driven where the day list shows it
The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.

Behind a later start the road trip reached such a stop late, and the
night spill took its earlier clock for a midnight: the stop and every
stop after it opened the next day's card, and a day left with one stop
fell to the placeholders at the bottom. A stop the schedule marks late
now stays on the day the schedule files it on, and the stops behind it
are read from its hour, so a clock that goes back past that hour still
starts the next day. The desktop rail, the phone's road trip tab and
the MCP calculate_roadtrip tool share that spill.
2026-09-22 21:48:22 +02:00
github-actions[bot] ed8acf4c4b chore: bump version to 4.3.1 [skip ci] 2026-09-22 12:17:29 +00:00
Maurice a280517ff3 Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
2026-09-22 13:30:38 +02:00
Maurice 2dd77aaac1 docs(wiki): the pages the 4.3.1 changes left behind
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
2026-09-22 13:30:12 +02:00
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00
Maurice cb85ba915c fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
2026-09-22 13:30:12 +02:00
Maurice 81292d4d2b fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
2026-09-22 13:30:12 +02:00
Maurice 757967bf18 fix(map): the location button follows the viewport instead of the mount
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
2026-09-22 12:57:04 +02:00
Maurice b0c19bfac1 fix(settings): the client registration form says what the server refused
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
2026-09-22 12:12:41 +02:00
Maurice da32eb8a3c fix(mcp): answer the discovery documents under the server address too
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
2026-09-22 12:12:41 +02:00
Maurice a44df6cf11 fix(planner): place edit and delete controls follow place_edit, not day_edit
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.

Closes #2446
2026-09-22 11:29:43 +02:00
Maurice e510ff25ab 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 a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
2026-09-22 11:06:13 +02:00
Maurice 34f3aeee5f 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 11:06:13 +02:00
Maurice bdd326163f 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 10:32:08 +02:00
Maurice b1baf3baa9 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 10:32:08 +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 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
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 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
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
1232 changed files with 104237 additions and 6675 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.
+27 -11
View File
@@ -27,7 +27,8 @@ 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 ./
@@ -52,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 \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev && \
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
@@ -117,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.2
description: Minimal Helm chart for TREK app
appVersion: "4.2.0"
appVersion: "4.3.2"
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.2.0",
"version": "4.3.2",
"private": true,
"type": "module",
"scripts": {
+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',
),
};
+27 -7
View File
@@ -580,24 +580,44 @@ describe('Version cache-busting', () => {
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a
// throwing step left it unwritten and the whole destructive path repeated on
// every launch, for good.
it('FE-COMP-APP-027: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => {
// A handover that never happened must not be recorded as done, or the
// device stays on the old build for good. The retry this buys has to be
// harmless: the purge that once repeated on every launch is gone, and a
// failure neither deletes anything nor reloads into the old worker's shell.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
const getRegistration = vi.fn().mockRejectedValue(new Error('no worker'))
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
HttpResponse.json({ version: '2.9.10', places_provider: 'google' })
)
)
seedAuth()
const first = renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1))
await act(async () => { await Promise.resolve() })
first.unmount()
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
renderApp('/')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2))
await act(async () => { await Promise.resolve() })
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
// Without a reload the session carries on, so the rest of the config applies.
expect(useAuthStore.getState().placesProvider).toBe('google')
})
})
+6 -44
View File
@@ -17,6 +17,7 @@ import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
@@ -284,7 +285,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,7 +302,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; 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> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; 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)
@@ -309,6 +310,7 @@ export default function App() {
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?.places_provider) setPlacesProvider(config.places_provider)
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,48 +320,8 @@ export default function App() {
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the
// characters a tag may contain and kept only when nothing had to be
// stripped. Anything else is ignored, which also keeps a malformed value
// from being compared against the stored marker and starting an update on
// every launch.
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
// mode, blocked site data, quota) left the caches deleted, the marker
// unwritten and the reload unreached, and the purge then repeated on
// every single launch, permanently (#2228).
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
if (storedVersion && storedVersion !== version) {
// A newer build is deployed. Ask the service worker to fetch it and
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
// installs the new precache and only then drops the outdated one, so
// there is never a moment without an app shell.
//
// This used to delete EVERY Cache Storage bucket and unregister EVERY
// worker instead. That left the device with no shell and no worker
// until a fresh ~22 MB precache finished (minutes on mobile data),
// and anyone who closed the app or lost signal in that window was
// left with a PWA that could no longer start offline at all. It also
// threw away the map tiles and file blobs the user had deliberately
// downloaded for offline use (#2228).
try {
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) {
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
await reg.update()
return
}
} catch { /* fall through to a plain reload */ }
window.location.reload()
return
}
}
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
}, [])
+281
View File
@@ -0,0 +1,281 @@
import React from 'react'
import { render, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { resetAllStores } from '../tests/helpers/store'
import App from './App'
/*
* #2458: an installed PWA kept serving the previous UI after a server upgrade.
*
* The launch probe compares the server's version with a marker in localStorage
* and, on a mismatch, asks the service worker to fetch the new build and reloads
* once it has taken over. These tests drive that probe across consecutive
* launches against a fake service-worker container, so a handover that never
* completed can be told apart from one that did.
*
* The fake is installed before App is imported (vi.hoisted), so code that
* notes the controlling worker when its module loads sees the worker that
* served this page, exactly as in the browser.
*/
const sw = vi.hoisted(() => {
class FakeWorker extends EventTarget {
state: string
readonly scriptURL = '/sw.js'
constructor(state: string) {
super()
this.state = state
}
become(state: string) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
readonly scope = '/'
update = vi.fn()
}
class FakeContainer extends EventTarget {
controller: FakeWorker | null = null
getRegistration = vi.fn()
// Listeners a page adds die with that page. Those added while modules load
// (before `trackPage` is switched on) stand for module-level code and stay.
trackPage = false
private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = []
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
super.addEventListener(type, listener, options)
if (this.trackPage && listener) this.pageListeners.push([type, listener, options])
}
dropPageListeners() {
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options)
this.pageListeners = []
}
}
const oldWorker = new FakeWorker('activated')
const registration = new FakeRegistration()
const container = new FakeContainer()
registration.active = oldWorker
container.controller = oldWorker
container.getRegistration.mockImplementation(async () => registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
return { FakeWorker, registration, container, oldWorker }
})
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }))
// Every import above has been evaluated by now; from here on a listener belongs to a page.
sw.container.trackPage = true
const reload = vi.fn()
let configHits = 0
function serveVersion(version: string, gate?: Promise<void>) {
server.use(
http.get('/api/auth/app-config', async () => {
configHits++
if (gate) await gate
return HttpResponse.json({ version })
}),
)
}
/** The browser's handover: the new worker installs, activates and claims the page. */
function handOver() {
const next = new sw.FakeWorker('activated')
sw.registration.installing = null
sw.registration.active = next
sw.container.controller = next
sw.container.dispatchEvent(new Event('controllerchange'))
}
/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */
function updateStartsInstall(then: 'activates' | 'fails') {
sw.registration.update.mockImplementation(async () => {
const next = new sw.FakeWorker('installing')
sw.registration.installing = next
setTimeout(() => {
if (then === 'activates') {
next.become('activated')
handOver()
} else {
// Workbox precaching is all-or-nothing: one failed fetch out of ~463
// rejects the install event and the new worker is thrown away.
sw.registration.installing = null
next.become('redundant')
}
}, 0)
return sw.registration
})
}
/** One cold start of the installed app: fresh page, fresh session, same localStorage. */
async function launch() {
sessionStorage.clear()
const hitsBefore = configHits
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(hitsBefore + 1))
// Let the probe, update() and the fake install run to completion.
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
configHits = 0
sw.registration.active = sw.oldWorker
sw.registration.installing = null
sw.registration.waiting = null
sw.registration.update.mockReset()
sw.registration.update.mockResolvedValue(sw.registration)
sw.container.controller = sw.oldWorker
sw.container.dropPageListeners()
sw.container.getRegistration.mockReset()
sw.container.getRegistration.mockImplementation(async () => sw.registration)
reload.mockReset()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })),
http.get('/api/system-notices/active', () => HttpResponse.json([])),
)
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
})
describe('#2458 version handover: behaviour that must survive the fix', () => {
it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => {
// A release that changed nothing the precache holds leaves sw.js byte-identical,
// so update() installs nothing. The bundle this page runs is already current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
})
it.each([
['a self-built image without the build arg', 'dev'],
['a test instance pinned to an older tag', '4.0.0'],
['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'],
])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => {
// The server reports APP_VERSION, the bundle carries client/package.json. On
// these setups the two never agree, so a check against the bundle's own
// version would see an update on every launch.
serveVersion(serverVersion)
await launch()
await launch()
await launch()
expect(reload).not.toHaveBeenCalled()
expect(sw.registration.update).not.toHaveBeenCalled()
})
})
describe('#2458 version handover: the reported defect', () => {
it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
// Next cold start, network fine this time.
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalled()
})
it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => {
// The old worker still controls the page, so a plain reload is answered from
// its precache: the same old UI, now with the marker saying it is current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('fails')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalledTimes(1)
})
// Kept last: it swaps the controlling worker mid-launch, which code that
// remembers "the controller changed since this page loaded" keeps seeing.
it('FE-COMP-APP-2458-04: a worker that took over before the probe answered still reloads the page', async () => {
// The browser checks sw.js on every navigation by itself. When that check
// installs and activates the new worker before /api/auth/app-config has
// answered, the takeover happens while nobody listens, and update() then
// finds nothing newer. The page keeps running the bundle the old worker served.
localStorage.setItem('trek_app_version', '4.3.0')
let open!: () => void
const gate = new Promise<void>(r => { open = r })
serveVersion('4.3.1', gate)
sessionStorage.clear()
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(1))
act(() => handOver())
open()
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
expect(reload).toHaveBeenCalledTimes(1)
})
})
+56 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-054
// FE-APISURF-001 to FE-APISURF-058
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 }
@@ -571,6 +572,11 @@ describe('client > request payloads', () => {
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
@@ -665,6 +671,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 +907,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)
})
})
+80 -6
View File
@@ -1,7 +1,7 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import type { Day, Place, Trip } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
@@ -434,11 +434,17 @@ export const tripsApi = {
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
// `trip` comes along when the new day changed the trip itself: a dated day
// (`dated: true`) moved its end date, an insert at a position grew it by a day.
create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> =>
apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
// Answers with the trip in list shape: its day count changed, and its end
// date when the day took the last date along.
delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> =>
apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
@@ -671,6 +677,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),
@@ -1050,7 +1058,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),
@@ -1211,14 +1222,19 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*
* `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 unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
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
@@ -1358,6 +1374,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),
+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 })
})
})
+13
View File
@@ -12,7 +12,11 @@ import type {
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -62,6 +66,15 @@ export const collectionsApi = {
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
+6
View File
@@ -81,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 />);
+40 -3
View File
@@ -9,6 +9,7 @@ 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'
@@ -41,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 {
@@ -95,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
@@ -137,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'))
}
@@ -145,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')
@@ -161,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 (
@@ -200,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]
@@ -248,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 (
@@ -1,4 +1,4 @@
// FE-W4BGT-001 to FE-W4BGT-022
// FE-W4BGT-001 to FE-W4BGT-024
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
@@ -276,3 +276,31 @@ describe('BackgroundTasksWidget — AI retry', () => {
expect(await screen.findByText('No model configured')).toBeInTheDocument()
})
})
/**
* The reporter's phone showed a green tick beside "No reservations could be
* extracted" and two warnings (#2477). A parse that found nothing is not a
* success, so it wears the warning mark; the tick stays for a parse that did.
*/
describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
it('FE-W4BGT-023: a job that found nothing shows the warning mark, not the success tick', () => {
useBackgroundTasksStore.setState({
tasks: [task({ items: [], warnings: ['Incomplete LodgingReservation in booking.pdf[0] (no reservationFor)'] })],
})
const { baseElement } = render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)')
expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull()
expect(baseElement.querySelector('.animate-spin')).toBeNull()
})
it('FE-W4BGT-024: a job with items keeps the success tick, even with warnings', () => {
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never, warnings: ['could not locate "X"'] })] })
const { baseElement } = render(<BackgroundTasksWidget />)
// The theme's own success colour, so it follows light and dark.
expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)')
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull()
})
})
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
@@ -141,8 +141,12 @@ export default function BackgroundTasksWidget() {
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
{task.status === 'done' && task.items !== undefined && task.items.length > 0 && <CheckCircle2 size={16} color="var(--success)" />}
{/* A parse that found nothing is not a success, whatever the job
status says: the same card used to show a green tick next to
"no reservations could be extracted" (#2477). */}
{task.status === 'done' && task.items?.length === 0 && <AlertTriangle size={16} color="var(--warning)" />}
{task.status === 'error' && <AlertCircle size={16} color="var(--danger)" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -744,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; settled_at?: string | null }
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 }
@@ -1185,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
+22 -8
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'
@@ -41,6 +41,9 @@ 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()
@@ -135,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) => convert(paidByUser(e, me), curOf(e))
// 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.
@@ -148,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(() => {
@@ -835,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>
@@ -887,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} />
@@ -898,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 && (
@@ -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>}
</>
}
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -63,7 +65,8 @@ describe('CollabLinks', () => {
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
expect(screen.getByText('https://ferries.example/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');
});
@@ -118,6 +121,108 @@ describe('CollabLinks', () => {
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-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
// The link goes for every member, and on the phone the button is a thumb's
// width from pin: the notes panel guards the same way.
const user = userEvent.setup();
let deleted = false;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
expect(deleted).toBe(false);
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
// The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText(/delete link\?|collab\.links\.confirmDeleteTitle/i).closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: /^(delete|common\.delete)$/i }));
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
});
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
// so the corrected address kept the generic glyph until the panel remounted.
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
);
render(<CollabLinks tripId={1} />);
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
await user.clear(url);
await user.type(url, 'https://new.example/page');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
});
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
const touch = css.slice(at, css.indexOf('\n}', at));
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
for (const side of ['width', 'height']) {
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
}
// The height comes back through the margin, so the chip itself stays 36px.
expect(action).toMatch(/margin:\s*-4px 0/);
});
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' } });
+77 -32
View File
@@ -1,11 +1,12 @@
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
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 ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
@@ -18,23 +19,34 @@ 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 { /* not a parseable url, fall through to the glyph */ }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
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 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.
* 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 AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
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
@@ -43,10 +55,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
if (!valid || busy) return
setBusy(true)
try {
await onCreate({ title: title.trim(), url: url.trim() })
await onSave({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// add() already said what went wrong; the form stays open with the input in it.
// The caller already said what went wrong; the form stays open with the input in it.
} finally {
setBusy(false)
}
@@ -66,7 +78,7 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
<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 }}>
@@ -99,6 +111,8 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<number | 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.
@@ -168,6 +182,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)
@@ -197,36 +222,56 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{/* 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 style={{ display: 'flex', flexDirection: 'column' }}>
<div className="collab-link-grid__cells">
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
<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')}>
{/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} 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>
<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>
</>}
{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={() => setPendingDeleteId(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 Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
{/* 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)} />}
{/* The delete is shared: the link goes for every member, and on the phone the
button is a thumb's width from pin. Same guard as the notes panel. */}
<ConfirmDialog
isOpen={pendingDeleteId !== null}
onClose={() => setPendingDeleteId(null)}
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
title={t('collab.links.confirmDeleteTitle')}
message={t('collab.links.confirmDeleteBody')}
/>
</div>
)
}
@@ -201,7 +201,9 @@ describe('CollabNotes', () => {
// Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
// The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText('Delete note?').closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
});
@@ -1,5 +1,5 @@
/**
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
* 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
@@ -11,7 +11,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StrictMode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
import { useChatImages, MAX_CHAT_IMAGES, type ChatImageDrop } from './useChatImages'
let nextUrl = 0
const revoked: string[] = []
@@ -92,4 +92,44 @@ describe('useChatImages', () => {
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()
})
})
+21 -4
View File
@@ -28,11 +28,24 @@ interface PickedImage {
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
}
@@ -52,7 +65,7 @@ export function useChatImages(): ChatImages {
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)
const room = MAX_CHAT_IMAGES - itemsRef.current.length
@@ -61,7 +74,11 @@ export function useChatImages(): ChatImages {
// 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) => {
@@ -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,
)}
</>
)
}
@@ -172,8 +172,10 @@ describe('CollectionHero', () => {
const all = [...document.querySelectorAll('.col-hero-actions button')];
expect(all).toEqual(actions);
// Export asks which format first (#2301).
await userEvent.click(actions[1]);
expect(onExport).toHaveBeenCalledTimes(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', () => {
@@ -186,8 +188,9 @@ describe('CollectionHero', () => {
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).toHaveBeenCalledTimes(1);
expect(onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
@@ -1,8 +1,10 @@
import React from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
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']
@@ -27,7 +29,7 @@ interface CollectionHeroProps {
shareMemberCount: number
onShare: () => void
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
onExport?: () => void
onExport?: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
@@ -98,19 +100,7 @@ export default function CollectionHero({
<span className="txt">{t('common.edit')}</span>
</button>
)}
{onExport && (
<button
type="button"
onClick={onExport}
disabled={exporting}
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>
)}
{onExport && <CollectionExportMenu onExport={onExport} exporting={exporting} t={t} />}
{canShare && (
<button
type="button"
@@ -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')} />
@@ -1,9 +1,10 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
// 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 { CollectionFile } from '@trek/shared';
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.
@@ -23,6 +24,7 @@ const listFile: 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,
};
@@ -33,7 +35,7 @@ function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollec
/** 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: 'application/json' });
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);
@@ -45,7 +47,6 @@ 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(screen.getByText('.trekcollection.json')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
@@ -142,3 +143,188 @@ describe('ImportCollectionModal (#2198)', () => {
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();
});
});
@@ -1,19 +1,152 @@
import React, { useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
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 type { CollectionFile } from '@trek/shared'
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, COLLECTION_FILE_EXTENSION } from './collectionFile'
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 and make a list of it (#2198).
* 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
@@ -22,27 +155,58 @@ interface ImportCollectionModalProps {
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
* the list editor.
*
* The file never leaves the browser as a file: it is parsed here, and what
* goes to the server is the parsed list, through the same contract the server
* validates against.
* 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, onClose, t }: ImportCollectionModalProps): React.ReactElement {
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) return
if (!chosen || reading) return
setError(null)
setFailedMessage(null)
const result = await readCollectionFile(chosen)
setFile(result.file)
setError(result.error)
if (result.file) setName(result.file.name)
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) => {
@@ -57,8 +221,12 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
setError(null)
setFailedMessage(null)
try {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
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')))
@@ -69,10 +237,10 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
const errorText = error === 'failed'
? (failedMessage ?? t('common.error'))
: error === 'too-large' ? t('collections.file.errorTooLarge')
: error === 'unreadable' ? t('collections.file.errorUnreadable')
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
: null
: 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
@@ -88,11 +256,11 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<button
type="button"
onClick={submit}
disabled={!file || busy || !name.trim()}
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" />}
{t('collections.file.confirm')}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button>
</div>
}
@@ -101,7 +269,7 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<input
ref={inputRef}
type="file"
accept=".json,application/json"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
@@ -113,19 +281,23 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
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'
}`}
>
<Upload size={22} className="text-content-faint" />
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
{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' }}>
<FileJson size={15} />
{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>
@@ -145,17 +317,47 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</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 && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
{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>}
</>
)}
</>
)}
@@ -1,6 +1,14 @@
// FE-COLL-FILE-001 to FE-COLL-FILE-012
// FE-COLL-FILE-001 to FE-COLL-FILE-020
import { describe, it, expect, vi, afterEach } from 'vitest'
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
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 => ({
@@ -8,10 +16,16 @@ const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
} as CollectionFile)
/** A File whose text() the jsdom build does not provide on its own. */
function asFile(content: string, size = content.length): File {
return { size, text: () => Promise.resolve(content) } as unknown as File
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)
@@ -23,69 +37,94 @@ describe('collectionFileName', () => {
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('downloadCollectionFile', () => {
afterEach(() => { vi.restoreAllMocks() })
describe('downloads', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
/** 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.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
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')
vi.useRealTimers()
vi.unstubAllGlobals()
})
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())))
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)
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'))).toEqual({ file: null, error: 'unreadable' })
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)), content).toEqual({ file: null, error: 'not-a-collection' })
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: [] })))
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: [] }))))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toEqual([])
})
@@ -94,20 +133,66 @@ describe('readCollectionFile', () => {
// 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 }))))
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' })))
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' })
})
})
@@ -1,34 +1,46 @@
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
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).
* The browser half of list export and import (#2198, #2301).
*
* The file itself is 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 the JSON to the browser as a download, and reads a chosen
* file back into something the import request will accept.
* 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 JSON and is treated as one: 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 to the server.
* 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): string {
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'}${COLLECTION_FILE_EXTENSION}`
return `${slug || 'collection'}${extension}`
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
const json = JSON.stringify(file, null, 2)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
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 = collectionFileName(file.name)
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
@@ -37,12 +49,30 @@ export function downloadCollectionFile(file: CollectionFile): void {
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
/** 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 the two is set.
* Exactly one of `file` and `error` is set; `gpx` only when the file was one.
*
* Two nullable fields rather than a discriminated union on an `ok` flag: the
* 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.
@@ -50,20 +80,57 @@ export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection
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 three failures are kept apart because they are three different things a
* person can do something about: a file too big to be one of ours, a file that
* is not JSON at all, and a JSON that is not a TREK list.
* 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): Promise<ParsedCollectionFile> {
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(await file.text())
raw = JSON.parse(text)
} catch {
return { file: null, error: 'unreadable' }
}
@@ -1,4 +1,4 @@
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-026
/**
* The review step between a detector's guess and a row in somebody's trip.
*
@@ -11,7 +11,8 @@
* 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, `journal` needs a journey and asks for a story, `bucket_list` needs 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),
@@ -173,20 +174,20 @@ describe('DawarichAcceptDialog', () => {
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.' } })
typeDate('2026-09-12')
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.',
date: '2026-09-12',
time: '09:00',
endTime: '11:45',
})
@@ -381,10 +382,11 @@ describe('DawarichAcceptDialog', () => {
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: ""`.
// 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({
trips: TRIPS,
daysForTrip,
target: 'journal',
journals: JOURNALS,
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
})
@@ -392,8 +394,8 @@ describe('DawarichAcceptDialog', () => {
expect(screen.getByLabelText('Left')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
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', () => {
@@ -524,4 +526,43 @@ describe('DawarichAcceptDialog', () => {
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' })
})
})
@@ -114,7 +114,10 @@ export default function DawarichAcceptDialog({
}
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
if (notes.trim()) body.notes = notes.trim()
if (date && date !== suggestion.localDate) body.date = date
// 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)
@@ -199,15 +202,27 @@ export default function DawarichAcceptDialog({
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. */}
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 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
<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
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')}
@@ -4,6 +4,7 @@ import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } fro
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'
@@ -123,6 +124,9 @@ export default function DawarichSuggestionsPanel({
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 (
@@ -277,13 +281,21 @@ export default function DawarichSuggestionsPanel({
* The actions fade in on hover on a pointer device and stay visible on touch,
* where there is no hover to reveal them.
*/
function SuggestionRow({
/**
* 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,
@@ -295,6 +307,8 @@ function SuggestionRow({
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
@@ -302,6 +316,9 @@ function SuggestionRow({
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]">
@@ -348,8 +365,8 @@ function SuggestionRow({
{showDate && (
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
)}
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</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">
@@ -7,6 +7,7 @@ import {
formatDayOption,
formatDuration,
groupByDay,
openStaysByDate,
timeRange,
} from './dawarichSuggestionModel'
@@ -105,12 +106,22 @@ describe('clockOf / timeRange', () => {
})
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')).toBe('10:15 – 12:40')
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
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')).toBe('')
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')
})
})
@@ -156,3 +167,49 @@ describe('formatDayOption', () => {
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)
})
})
@@ -1,5 +1,6 @@
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
@@ -33,7 +34,9 @@ export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
else byDate.set(stay.localDate, [stay])
}
const oldestFirst = [...byDate.keys()].sort()
// 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,
@@ -44,6 +47,30 @@ export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
.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) : ''
@@ -54,10 +81,15 @@ export function clockOf(iso: string): string {
*
* 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): string {
const from = clockOf(startedAt)
const to = clockOf(endedAt)
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
}
@@ -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,583 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-023
/**
* 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),
},
}
})
/** Set by the one test that needs a render slower than React's time slice. */
let slowCard = false
vi.mock('./DocSyncBinding', async (importOriginal) => {
const actual = await importOriginal<typeof import('./DocSyncBinding')>()
const Card = actual.default
return {
...actual,
default: function SlowableBinding(props: Parameters<typeof Card>[0]) {
// Busy for longer than the scheduler's 5 ms slice, so the commit that
// shows the card leaves its effects for a later task, as a loaded CI
// runner does.
if (slowCard) {
const end = performance.now() + 20
while (performance.now() < end) { /* busy */ }
}
return <Card {...props} />
},
}
})
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()
slowCard = false
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')
})
it('FE-DOCSYNC-PANEL-023: a store picked before the list-following effect has run stays picked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
slowCard = true
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// Outside act, as during the wait inside findBy*: React commits the card on
// its own scheduler, and the click lands the moment the card is in the DOM,
// before the effect that follows the list has had its turn.
const env = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
const previous = env.IS_REACT_ACT_ENVIRONMENT
env.IS_REACT_ACT_ENVIRONMENT = false
try {
await new Promise<void>(resolve => {
const observer = new MutationObserver(() => {
if (!document.querySelector('article')) return
observer.disconnect()
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
resolve()
})
observer.observe(document.body, { childList: true, subtree: true })
})
} finally {
env.IS_REACT_ACT_ENVIRONMENT = previous
}
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
})
})
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,429 @@
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. The pick
// is read when the update runs, not from this render: a store clicked before
// the effect got its turn was otherwise switched straight back.
useEffect(() => {
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links])
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))
})
})
@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useNetworkMode } from '../../../hooks/useNetworkMode'
import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore'
import { useServerPing } from '../../../sync/useServerPing'
/**
* Whether this trip's Files screen shows the "Document sync" button.
*
* One hook for both shells, so the toolbar and the phone header cannot drift
* on who sees it. The last known answer renders straight away; the server is
* asked again when the screen mounts and when the connection comes back, and
* never while offline, where the button keeps whatever it last knew.
*
* It is also asked on every `docsync:changed`, which the server sends when a
* binding comes or goes. A member can sit on the Files tab while the owner
* binds the trip, and on desktop that tab may never remount before a reload.
*/
export function useDocSyncOffered(tripId: number | string, canManage: boolean): boolean {
const offered = useDocSyncOfferStore(
s => s.bound[String(tripId)] === true || (canManage && s.providers === true),
)
const refresh = useDocSyncOfferStore(s => s.refresh)
const { offline } = useNetworkMode()
useEffect(() => {
if (!offline) void refresh(tripId, canManage)
}, [tripId, canManage, offline, refresh])
// Off while offline: the effect above already asks on the way back, and the
// ping's own return-to-online refetch would only ask a second time.
useServerPing('docsync:changed', !offline, () => refresh(tripId, canManage))
return offered
}
@@ -6,6 +6,9 @@ import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { canManageDocSync } from './docsync/useDocSync'
import { useDocSyncOffered } from './docsync/useDocSyncOffered'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
@@ -30,6 +33,7 @@ export interface FileManagerProps {
*/
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [showDocSync, setShowDocSync] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
@@ -38,6 +42,9 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const currentUser = useAuthStore((s) => s.user)
const canManageSync = canManageDocSync(currentUser, trip)
const docSyncOffered = useDocSyncOffered(tripId, canManageSync)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -210,6 +217,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, mediaFiles, openFile,
showDocSync, setShowDocSync, canManageSync, docSyncOffered,
}
}
@@ -0,0 +1,147 @@
// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-008
/**
* The fold a journal day carries for the stays Dawarich recorded on it.
*
* The rows inside it are `SuggestionRow`, which the panel's own suite pins end to end, so
* what is worth holding here is the fold: that it says nothing on a quiet day, that the
* line reads before it is opened, and that the two actions reach the journal with the
* stay they belong to. The whole point of this component is that forty stays no longer
* stand between a reader and their first entry, and that only works while it starts shut.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import type { DawarichSuggestion } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore'
import JourneyDayDawarich from './JourneyDayDawarich'
function stay(over: Partial<DawarichSuggestion> & { id: number }): DawarichSuggestion {
const date = over.localDate ?? '2026-09-10'
return {
id: over.id,
sourceVisitId: String(over.id),
tripId: null,
tripTitle: null,
name: over.name ?? 'Cafe Reichard',
lat: 50.94,
lng: 6.96,
startedAt: over.startedAt ?? `${date}T10:15:00+02:00`,
endedAt: over.endedAt ?? `${date}T12:40:00+02:00`,
durationMinutes: over.durationMinutes ?? 145,
localDate: date,
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: `${date}T06:00:00Z`,
lastSeenAt: `${date}T06:00:00Z`,
...over,
} as DawarichSuggestion
}
function fold(suggestions: DawarichSuggestion[], over: Partial<{ busyId: number | null }> = {}) {
const onAccept = vi.fn()
const onDismiss = vi.fn()
const view = render(
<JourneyDayDawarich
suggestions={suggestions}
busyId={over.busyId ?? null}
onAccept={onAccept}
onDismiss={onDismiss}
/>,
)
return { onAccept, onDismiss, ...view }
}
const head = () => screen.getByRole('button', { expanded: false }) ?? screen.getByRole('button')
describe('JourneyDayDawarich', () => {
it('FE-JRN-DAYDAW-001: a day with nothing pending draws nothing at all', () => {
// Not an empty row saying so: a connected, quiet integration is invisible, and this
// one would otherwise appear on every single day of the journey.
const { container } = fold([])
expect(container).toBeEmptyDOMElement()
})
it('FE-JRN-DAYDAW-002: shut, it says how many and which part of the day they cover', () => {
fold([
stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }),
stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }),
])
expect(screen.getByText('2 stays from Dawarich')).toBeInTheDocument()
// First arrival to last departure, not each stay's own range.
expect(screen.getByText('08:00 – 17:08')).toBeInTheDocument()
})
it('FE-JRN-DAYDAW-003: one stay takes the singular', () => {
// A single key with a placeholder would read "1 stays".
fold([stay({ id: 1 })])
expect(screen.getByText('1 stay from Dawarich')).toBeInTheDocument()
})
it('FE-JRN-DAYDAW-004: it starts shut, and the stays are not in the page until it opens', () => {
fold([stay({ id: 1, name: 'Cafe Reichard' })])
expect(screen.queryByText('Cafe Reichard')).toBeNull()
expect(head()).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(head())
expect(screen.getByText('Cafe Reichard')).toBeInTheDocument()
})
it('FE-JRN-DAYDAW-005: open, each stay is a row with its own two actions', () => {
const { onDismiss } = fold([stay({ id: 7, name: 'Cafe Reichard' })])
fireEvent.click(head())
const row = screen.getByText('Cafe Reichard').closest('div')!.parentElement!
fireEvent.click(within(row).getByRole('button', { name: 'Not a place I visited' }))
expect(onDismiss).toHaveBeenCalledTimes(1)
expect(onDismiss.mock.calls[0][0]).toMatchObject({ id: 7 })
})
it('FE-JRN-DAYDAW-006: accepting hands back the stay it was asked about and where it goes', () => {
const { onAccept } = fold([stay({ id: 7, name: 'Cafe Reichard' })])
fireEvent.click(head())
fireEvent.click(screen.getByRole('button', { name: /journal|journey/i }))
expect(onAccept).toHaveBeenCalledTimes(1)
expect(onAccept.mock.calls[0][0]).toMatchObject({ id: 7 })
expect(onAccept.mock.calls[0][1]).toBe('journal')
})
it('FE-JRN-DAYDAW-007: the stay a write is in flight for is the one that goes busy', () => {
fold([stay({ id: 7, name: 'Cafe Reichard' }), stay({ id: 8, name: 'Zum Treppchen' })], { busyId: 7 })
fireEvent.click(head())
const busyRow = screen.getByText('Cafe Reichard').closest('div')!.parentElement!
const idleRow = screen.getByText('Zum Treppchen').closest('div')!.parentElement!
expect(within(busyRow).getAllByRole('button')[0]).toBeDisabled()
expect(within(idleRow).getAllByRole('button')[0]).not.toBeDisabled()
})
it('FE-JRN-DAYDAW-008: the span follows the traveller own clock', () => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '12h' } }))
fold([
stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }),
stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }),
])
expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument()
useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } }))
})
})
@@ -0,0 +1,97 @@
import React, { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { useTranslation } from '../../i18n'
import DawarichIcon from '../shared/DawarichIcon'
import { SuggestionRow } from '../Dawarich/DawarichSuggestionsPanel'
import { timeRange } from '../Dawarich/dawarichSuggestionModel'
import { useSettingsStore } from '../../store/settingsStore'
/**
* The stays Dawarich recorded on ONE day of the journal, folded into that day.
*
* They used to be a panel above the timeline: every open stay of every day, stacked over
* the entries, so a fortnight of driving put forty rows between the reader and their own
* first entry. The list belongs where the day is (discussion with Roel, 16.09.), and a
* day of driving still produces four to eleven stays, so the day shows one line with a
* count and opens on a tap.
*
* Shut, it reads as one more card in the day: the mark says where it came from without a
* word of explanation, and the times say which part of the day it covers. Open, it is the
* same rows the panel drew, which is why `SuggestionRow` is imported rather than copied.
*
* It never appears on a day with nothing pending: an integration that is connected and
* quiet should be invisible, not a permanent empty row on every day of the journey.
*/
export default function JourneyDayDawarich({
suggestions,
busyId,
onAccept,
onDismiss,
}: {
/** The open stays of this day, in the order they were lived. */
suggestions: DawarichSuggestion[]
/** The stay a write is in flight for, or null. */
busyId: number | null
onAccept: (suggestion: DawarichSuggestion, target: DawarichSuggestionTarget) => void
onDismiss: (suggestion: DawarichSuggestion) => void
}): React.ReactElement | null {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
if (suggestions.length === 0) return null
// From the first arrival to the last departure: what part of the day this covers, in
// one line, so the row says something even while it is shut. Through the same formatter
// the rows use, so the summary and the stays under it cannot disagree about the clock.
const span = timeRange(suggestions[0]!.startedAt, suggestions[suggestions.length - 1]!.endedAt, is12h)
return (
<div className="rounded-2xl border border-edge bg-surface-secondary overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
>
{/* The brand mark in its own colours, which is what tells this row from an entry
at a glance. No second label saying "Dawarich": the mark is the label. */}
<DawarichIcon size={18} className="shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-content">
{/* Two keys picked by the count, the shape this codebase uses for a number
that changes its noun: there is no plural engine in the i18n layer, and one
key with {count} in it reads as "1 stays". */}
{t(
suggestions.length === 1 ? 'dawarich.journey.dayStays.one' : 'dawarich.journey.dayStays.other',
{ count: suggestions.length },
)}
</span>
{span && <span className="block text-[11px] text-content-faint tabular-nums">{span}</span>}
</span>
{open
? <ChevronDown size={16} className="shrink-0 text-content-faint" aria-hidden />
: <ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />}
</button>
{open && (
<div className="flex flex-col gap-1 pb-2">
{suggestions.map(suggestion => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
busy={busyId === suggestion.id}
// The journal is the one place a stay goes from here, and every row in this
// list is already on the day it belongs to, so neither the date nor a trip
// name adds anything.
allowJournal
onAccept={target => onAccept(suggestion, target)}
onDismiss={() => onDismiss(suggestion)}
/>
))}
</div>
)}
</div>
)
}
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -1028,4 +1028,47 @@ describe('EntryEditor', () => {
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-056: a clip without a poster is a play badge, not a request for its thumbnail (#2341)', () => {
// The thumbnail route answers 404 for such a clip on purpose, and the old
// fallback to /original would have handed an <img> the video file itself.
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-057: a clip with its poster shows the poster like any photo', () => {
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
})
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A photo whose thumbnail is missing can still be drawn from its original. A
// clip's original is the video file, which no <img> can show, so a clip gets
// no fallback (#2341).
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
if (p.media_type === 'video') return undefined
return (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
}
}
// No poster to show and falling back to /original would hand an <img> the clip
// itself, so this tile stays a play badge.
function ClipTile() {
return (
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
)
}
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
@@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -527,7 +552,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(p) ? (
<ClipTile />
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
)}
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
@@ -8,7 +8,7 @@ import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
@@ -151,9 +151,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
>
{photo.media_type === 'video' &&
photo.provider === 'local' &&
!photo.thumbnail_path ? (
{posterlessVideo(photo) ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { PhotoImg } from './JourneyDetailPagePhotoGrid'
import type { JourneyPhoto } from '../../store/journeyStore'
function buildPhoto(overrides: Partial<JourneyPhoto> = {}): JourneyPhoto {
return {
id: 1,
entry_id: 10,
photo_id: 515,
sort_order: 0,
shared: 1,
created_at: 0,
provider: 'local',
...overrides,
}
}
describe('PhotoImg', () => {
it('asks for the thumbnail of a photo', () => {
const { container } = render(<PhotoImg photo={buildPhoto()} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
it('shows the poster of a clip behind the play badge', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: 'journey/poster.jpg' })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
expect(container.querySelector('svg')).not.toBeNull()
})
it('draws no image for a clip without a poster, whose thumbnail route answers 404 (#2341)', () => {
// The tile stays black with the play badge on it. An <img> here would only
// add the broken-image glyph the reporter saw.
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: null })} />)
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
expect(container.firstElementChild?.className).toContain('bg-black')
})
it('still asks the server for a provider clip, which serves its own poster', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', provider: 'immich', thumbnail_path: null })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
})
@@ -1,6 +1,6 @@
import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail')
@@ -11,12 +11,16 @@ export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; cla
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style}
>
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{/* A clip without a poster keeps the black tile and the play badge; asking
for its thumbnail would only draw the broken-image glyph over them. */}
{!posterlessVideo(photo) && (
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
)}
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@@ -1,6 +1,6 @@
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
// FE-JRN-PICKER-001 to FE-JRN-PICKER-023
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, afterAll, beforeAll, beforeEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
@@ -54,6 +54,16 @@ function mountPicker(props: Partial<React.ComponentProps<typeof ProviderPicker>>
return { ...utils, onClose, onAdd }
}
// The day headings follow the reader's wall clock now, so they would follow the
// runner's zone too. Nothing in the vitest config pins one, so this file does.
const runnerTimeZone = process.env.TZ
beforeAll(() => { process.env.TZ = 'UTC' })
afterAll(() => {
if (runnerTimeZone === undefined) delete process.env.TZ
else process.env.TZ = runnerTimeZone
})
beforeEach(() => {
searchReturns([asset('a1')])
})
@@ -103,7 +113,9 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: /All Photos/ }))
await waitFor(() => expect(ranges).toHaveLength(2))
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 50 })
// utc_offset_minutes rides along on every search: the server cannot tell
// which 24 hours a date-only bound means without it (#2336).
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 50, utc_offset_minutes: 0 })
})
it('FE-JRN-PICKER-006: the album tab loads albums and reports when there are none', async () => {
@@ -286,6 +298,55 @@ describe('ProviderPicker', () => {
}
})
it('FE-JRN-PICKER-022: says which zone the searched day is meant in', async () => {
const ranges: Record<string, unknown>[] = []
server.use(http.post('/api/integrations/memories/immich/search', async ({ request }) => {
ranges.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ assets: [asset('a1')], hasMore: false })
}))
process.env.TZ = 'Australia/Sydney'
try {
mountPicker({ initialDate: '2026-03-15' })
await waitFor(() => expect(ranges).toHaveLength(1))
} finally {
process.env.TZ = 'UTC'
}
// +11 on 15 March, read at local noon of the day being searched rather than
// at "now" — Sydney is +10 for half the year, so a day picked out of the
// other half would otherwise be an hour off.
expect(ranges[0]).toMatchObject({ from: '2026-03-15', to: '2026-03-15', utc_offset_minutes: 660 })
})
it('FE-JRN-PICKER-023: keeps paging when a page holds no photos of the searched day', async () => {
// The local-day filter can empty a whole page (50 shots taken the next
// morning). The sentinel used to live inside the grid, so an empty page
// mounted none and the picker sat on "no photos" forever (#2336).
const pages: number[] = []
server.use(http.post('/api/integrations/memories/immich/search', async ({ request }) => {
const body = await request.json() as { page: number }
pages.push(body.page)
return HttpResponse.json({ assets: body.page < 2 ? [] : [asset('a1')], hasMore: body.page < 2 })
}))
class ImmediateObserver {
constructor(private cb: IntersectionObserverCallback) {}
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
unobserve() {}
disconnect() {}
}
const original = globalThis.IntersectionObserver
globalThis.IntersectionObserver = ImmediateObserver as unknown as typeof IntersectionObserver
try {
mountPicker()
await waitFor(() => expect(pages).toEqual([1, 2]))
expect(await screen.findAllByAltText('')).toHaveLength(1)
// And the empty first page never claimed there were none to find.
expect(screen.queryByText('No photos yet')).not.toBeInTheDocument()
} finally {
globalThis.IntersectionObserver = original
}
})
it('FE-JRN-PICKER-018: the embedded variant drops the header, the add-to bar and the date captions', async () => {
mountPicker({ embedded: true, initialDate: '2026-03-15' })
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n';
import {
groupPhotosByDate,
sortProviderPhotos,
utcOffsetMinutesForDay,
type GeoPoint,
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
@@ -94,7 +95,17 @@ export function ProviderPicker({
setSearchTo(to);
setSearchPage(page);
try {
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
// The bounds are calendar days as this browser reads them, so the zone they
// are meant in travels with them. Without it the server can only take them
// as UTC days and a reader outside UTC gets somebody else's 24 hours
// (#2336). A single offset leaves a one-hour sliver at the far end of a
// multi-day range that crosses a DST change, and none at all for a single
// day, which is the case this fixes.
const data = await memoriesApi.search(
provider,
{ from, to, page, size: 50, utc_offset_minutes: utcOffsetMinutesForDay(from || to) },
signal,
);
const assets = data.assets || [];
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
setHasMore(!!data.hasMore);
@@ -162,6 +173,9 @@ export function ProviderPicker({
[photos, contextLocation?.lat, contextLocation?.lng],
);
// Albums come back whole, so they never page.
const morePagesPending = hasMore && !selectedAlbum;
const toggleAsset = (id: string) => {
setSelected((prev) => {
const next = new Map(prev);
@@ -474,82 +488,91 @@ export function ProviderPicker({
<div className="flex justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
</div>
) : sortedPhotos.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from
? t('journey.trips.noTripsLinkedSettings')
: t('journey.detail.noPhotos')}
</p>
</div>
) : (
<div>
{groupPhotosByDate(sortedPhotos).map((group) => (
<div key={group.date}>
{(!embedded || filter !== 'day') && (
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
{group.label}
</p>
)}
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id);
const alreadyAdded = existingAssetIds.has(asset.id);
return (
<button
type="button"
key={asset.id}
disabled={alreadyAdded}
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
alreadyAdded
? 'cursor-not-allowed opacity-40'
: isSelected
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const img = e.currentTarget;
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
if (!img.src.includes('/original')) img.src = original;
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
</button>
);
})}
</div>
{sortedPhotos.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from
? t('journey.trips.noTripsLinkedSettings')
: morePagesPending
? t('common.loading')
: t('journey.detail.noPhotos')}
</p>
</div>
))}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
) : (
<div>
{groupPhotosByDate(sortedPhotos).map((group) => (
<div key={group.date}>
{(!embedded || filter !== 'day') && (
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
{group.label}
</p>
)}
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id);
const alreadyAdded = existingAssetIds.has(asset.id);
return (
<button
type="button"
key={asset.id}
disabled={alreadyAdded}
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
alreadyAdded
? 'cursor-not-allowed opacity-40'
: isSelected
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const img = e.currentTarget;
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
if (!img.src.includes('/original')) img.src = original;
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
</button>
);
})}
</div>
</div>
))}
</div>
)}
{/* Outside the empty/grid choice on purpose: a page whose photos all
belong to a neighbouring day leaves the grid empty, and a sentinel
that only exists next to photos would never ask for the next page
(#2336). */}
{morePagesPending && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div>
)}
</div>
@@ -377,8 +377,11 @@ describe('JourneySettingsDialog', () => {
it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => {
const { onRefresh, onSaved } = mountDialog()
const sw = screen.getByRole('switch', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-checked', 'false')
// The shared ToggleSwitch is a button with aria-pressed, not a role="switch" with
// aria-checked: the dialog drew its own switches by hand until they turned out to be
// white on near-white in the dark.
const sw = screen.getByRole('button', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-pressed', 'false')
await userEvent.click(sw)
@@ -393,8 +396,8 @@ describe('JourneySettingsDialog', () => {
// The column is INTEGER, so what arrives over the wire is 1, not true.
mountDialog(buildJourney({ show_trip_tracks: 1 }))
const sw = screen.getByRole('switch', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-checked', 'true')
const sw = screen.getByRole('button', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-pressed', 'true')
await userEvent.click(sw)
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false }))
@@ -404,7 +407,7 @@ describe('JourneySettingsDialog', () => {
updateJourney.mockRejectedValueOnce(new Error('nope'))
const { onClose } = mountDialog()
await userEvent.click(screen.getByRole('switch', { name: /GPX/i }))
await userEvent.click(screen.getByRole('button', { name: /GPX/i }))
await waitFor(() => expect(toastSpy).toHaveBeenCalled())
expect(onClose).not.toHaveBeenCalled()
@@ -11,6 +11,7 @@ import type { JourneyDetail } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import { normalizeImageFile } from '../../utils/convertHeic'
import ToggleSwitch from '../Settings/ToggleSwitch'
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
journey: JourneyDetail
@@ -188,25 +189,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
{/* Trip GPX tracks on the journey map (#2194) */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.tracks')}</label>
<button
type="button"
role="switch"
aria-checked={!!journey.show_trip_tracks}
disabled={savingTracks}
onClick={handleTracksToggle}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
>
<div className={`w-full flex items-center gap-3 px-3.5 py-2.5 border border-edge rounded-xl bg-surface-card text-left${savingTracks ? ' opacity-60' : ''}`}>
<span className="flex-1 min-w-0">
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.settings.showTripTracks')}</span>
<span className="block text-[11px] text-zinc-500">{t('journey.settings.showTripTracksHint')}</span>
<span className="block text-[14px] text-content-primary">{t('journey.settings.showTripTracks')}</span>
<span className="block text-[11px] text-content-faint">{t('journey.settings.showTripTracksHint')}</span>
</span>
<span
aria-hidden="true"
className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${journey.show_trip_tracks ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}
>
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${journey.show_trip_tracks ? 'translate-x-4' : ''}`} />
</span>
</button>
<ToggleSwitch
on={!!journey.show_trip_tracks}
onToggle={() => { if (!savingTracks) handleTracksToggle() }}
label={t('journey.settings.showTripTracks')}
/>
</div>
</div>
{/* The three fields a journey may put away (discussion #2299) */}
@@ -221,21 +214,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
] as const).map(([field, label]) => {
const on = journey[field] !== 0
return (
<button
<div
key={field}
type="button"
role="switch"
aria-checked={on}
aria-label={label}
disabled={savingField !== null}
onClick={() => handleFieldToggle(field)}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
className={`w-full flex items-center gap-3 px-3.5 py-2.5 border border-edge rounded-xl bg-surface-card text-left${savingField !== null ? ' opacity-60' : ''}`}
>
<span className="flex-1 min-w-0 text-[14px] text-zinc-900 dark:text-white">{label}</span>
<span aria-hidden="true" className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${on ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}>
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${on ? 'translate-x-4' : ''}`} />
</span>
</button>
<span className="flex-1 min-w-0 text-[14px] text-content-primary">{label}</span>
<ToggleSwitch
on={on}
onToggle={() => { if (savingField === null) handleFieldToggle(field) }}
label={label}
/>
</div>
)
})}
</div>
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008
// FE-JRN-COVER-001 to FE-JRN-COVER-010
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce()
})
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
// across the whole card.
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
})
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
})
@@ -1,6 +1,7 @@
import { MapPin, Plus } from 'lucide-react'
import { MapPin, Play, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all.
*/
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
export interface CoverPhoto extends CardPhoto {
media_type?: string | null
provider?: string | null
thumbnail_path?: string | null
}
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry {
id: number
@@ -39,7 +47,7 @@ export interface CoverEntry {
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CardPhoto[]
photos?: CoverPhoto[]
}
interface Props {
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const first = entry.photos?.[0]
const photoId = cardPhotoId(first)
// A clip without a poster has no picture to fill the card with; its thumbnail
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
const posterless = first != null && posterlessVideo(first)
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
@@ -112,7 +124,9 @@ export default function JourneyEntryCover({
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
</span>
)}
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
setView: vi.fn(),
flyTo: vi.fn(),
panTo: vi.fn(),
// Runs the callback at once on a map with a view, else on its load event.
// The default is the loaded map; the two "no view yet" tests below hold
// the callback back and release it by hand.
whenReady: vi.fn((cb: () => void) => { cb(); }),
getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
@@ -334,13 +338,21 @@ describe('JourneyMap', () => {
expect(mockedMap().flyTo).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
it('FE-COMP-JOURNEYMAP-018: focusMarker waits for the first view when the map has none yet', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
// Leaflet does not refuse a pan on a viewless map, it takes it as the first
// view with the zoom undefined, and the tile layer then aborts the load event
// before the markers are added. So the pan has to wait for the fit.
let onLoad: (() => void) | null = null;
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
expect(mockedMap().flyTo).not.toHaveBeenCalled();
act(() => { ref.current!.focusMarker('e1'); });
expect(mockedMap().panTo).not.toHaveBeenCalled();
act(() => { onLoad!(); });
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
});
it('FE-COMP-JOURNEYMAP-019: invalidateSize forwards to the leaflet map', () => {
@@ -482,14 +494,22 @@ describe('JourneyMap', () => {
}
});
it('FE-COMP-JOURNEYMAP-029: activeMarkerId falls back to setView while the map has no view yet', () => {
it('FE-COMP-JOURNEYMAP-029: activeMarkerId waits for the first view while the map has none yet', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
// The 50 ms timer can beat the rAF that sets the first view, in a
// background tab most of all. A pan at that point used to become the first
// view itself, with no zoom, and left the map without its markers; the next
// rebuild then crashed the page in Leaflet's icon removal.
let onLoad: (() => void) | null = null;
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
act(() => { vi.advanceTimersByTime(60); });
// The catch is where a map with no view at all gets its first one.
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
expect(mockedMap().panTo).not.toHaveBeenCalled();
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
act(() => { onLoad!(); });
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
expect(mockedMap().flyTo).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
+23 -13
View File
@@ -161,6 +161,27 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
</div>`
}
/**
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
* and a focus keeps that (discussion #2299).
*
* Deferred until the map has its first view. That view is set on a rAF after
* the build, and the active entry asks for its pan on a 50 ms timer, so a tab
* in the background or a slow frame lets the pan come first. 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 marker queued behind it never makes it onto the map.
* The next rebuild then tears down markers that were never added and dies in
* Leaflet's icon removal, which took the whole journey page with it after each
* save. whenReady runs the pan at once on a map with a view and otherwise right
* after the fit lands.
*/
function panToMarker(map: L.Map, marker: L.Marker): void {
map.whenReady(() => {
map.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
})
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
@@ -254,11 +275,7 @@ function JourneyMap(
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
try {
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch { /* map not yet initialized */ }
}
if (marker && mapRef.current) panToMarker(mapRef.current, marker)
}, [])
const invalidateSize = useCallback(() => {
@@ -511,14 +528,7 @@ function JourneyMap(
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
// fires, and panTo on a map with no view throws "Set map center and zoom
// first", so the catch is where the map gets its first one.
try {
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
}
panToMarker(mapRef.current, marker)
}, 50)
return () => clearTimeout(timer)
}, [activeMarkerId])
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
})
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
// Its original is the video file, which an <img> cannot show, so the hero used
// to be a broken image for every clip, poster or not.
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
// The second photo's strip thumbnail is the only image left.
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
})
@@ -7,6 +7,7 @@ import {
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, 0)}
>
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{/* A clip is drawn from its poster, its original being the video file.
Without a poster the black ground and the play badge are the hero (#2341). */}
{posterlessVideo(photos[0]) ? (
<span className="block aspect-video w-full max-h-[50vh]" />
) : (
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, i)}
>
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn()
vi.mock('plyr', () => ({
default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
}
},
}))
vi.mock('plyr/dist/plyr.css', () => ({}))
import { render } from '../../../tests/helpers/render'
import VideoPlayer from './VideoPlayer'
describe('VideoPlayer', () => {
beforeEach(() => { plyrCtor.mockClear() })
it('hands Plyr the sprite from this origin, not the one on cdn.plyr.io (#2341)', () => {
// connect-src refuses cdn.plyr.io, so with the default the player worked and
// every button in it was blank. The sprite ships with the bundle instead.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(1)
const [, options] = plyrCtor.mock.calls[0]
// A hashed asset URL in the bundle, the raw file (with its query) under vitest.
expect(String(options.iconUrl)).toMatch(/plyr[^/]*\.svg(\?|$)/)
expect(String(options.iconUrl)).not.toContain('cdn.plyr.io')
})
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => {
// media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is out of the document by then.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0]
expect(options.blankVideo).toBe('')
})
it('mounts the player on the video element it renders', () => {
const { container } = render(<VideoPlayer src="/api/photos/7/original" poster="/api/photos/7/thumbnail" />)
const [el] = plyrCtor.mock.calls[0]
expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
})
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
// the next clip, and destroy() then swapped the first clip back in. The file
// lightboxes do not key the player themselves, so this has to hold here.
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(2)
const [first] = plyrCtor.mock.calls[0]
const [second] = plyrCtor.mock.calls[1]
expect(second).not.toBe(first)
expect(second).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
})
})
+29 -12
View File
@@ -1,6 +1,18 @@
import React, { useEffect, useRef } from 'react'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
// The control icons, served from this origin. Plyr's default points at cdn.plyr.io,
// which connect-src refuses: the player worked but every button in it was blank.
// A file, not a data URL, because Plyr tells same-origin from cross-origin by the
// host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
@@ -8,18 +20,17 @@ import 'plyr/dist/plyr.css'
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*
* A new source is a new element: Plyr's destroy() puts a clone of the element as
* it was at construction back into the document, so rebuilding the player on a
* <video> React had already pointed at the next clip left the first clip on
* screen under the next one's name.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
export default function VideoPlayer(props: Props): React.ReactElement {
return <PlyrVideo key={props.src} {...props} />
}
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
@@ -40,6 +51,12 @@ export default function VideoPlayer({
autoplay: autoPlay,
clickToPlay: true,
hideControls: false,
iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream,
// by default one on cdn.plyr.io, which media-src refuses. An empty source
// aborts the stream just the same, and the element is out of the document
// by then, so the error it would otherwise report has nowhere to show.
blankVideo: '',
})
return () => {
@@ -49,7 +66,7 @@ export default function VideoPlayer({
/* already torn down */
}
}
}, [src, autoPlay])
}, [autoPlay])
return (
<div
@@ -1,8 +1,8 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-005
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -41,4 +41,17 @@ describe('MapLayerSwitcher', () => {
fireEvent.mouseLeave(button)
expect(button.style.background).toBe('transparent')
})
it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
const shell = button.parentElement as HTMLElement
// The phone's compass stands beside the switcher at inset + size + gap. If the
// markup grew without the constant, the compass would slide back under the shell.
expect(shell.style.padding).toBe('4px')
expect(button.style.width).toBe('34px')
expect(button.style.height).toBe('34px')
expect(parseFloat(button.style.width) + 2 * parseFloat(shell.style.padding)).toBe(MAP_ROUND_CONTROL_SIZE)
})
})
+16 -3
View File
@@ -5,9 +5,22 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
// Round base-layer switcher for the Leaflet planner map (default street tiles ↔
// satellite). Same frosted shell as MapCompassPill so it lines up with the other
// map controls; the icon shows the layer it switches to.
// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
// Both map engines place the switcher with the inset, so they cannot drift apart either.
/** Distance from the map's left edge (plus any side panel) to the switcher. */
export const MAP_LAYER_SWITCHER_INSET = 20
/**
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
* them to this number.
*/
export const MAP_ROUND_CONTROL_SIZE = 42
// Round base-layer switcher for both planner maps, Leaflet and GL (default street
// tiles ↔ satellite). Same frosted shell as MapCompassPill so it lines up with the
// other map controls; the icon shows the layer it switches to.
export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onToggle: () => void }) {
const { t } = useTranslation()
const isSatellite = active === 'satellite'
+235 -4
View File
@@ -56,6 +56,37 @@ vi.mock('../../hooks/useGeolocation', () => ({
// after the map already rendered.
const thumbCallbacks = vi.hoisted(() => new Map<string, (thumb: string) => void>())
// The cluster group and the Leaflet markers MapView reaches for when a selection lands
// on a stop that shares its coordinates and so has no pin of its own. `stacked` decides
// whether the group answers with a bubble or with the marker itself.
const clusterMock = vi.hoisted(() => {
const spiderfy = vi.fn()
return {
spiderfy,
stacked: false,
asked: [] as unknown[],
markers: new Map<Element, object>(),
/**
* One marker double per pin, not per coordinate. Stops that share a coordinate are
* the whole point here, and a registry keyed by place id only proves anything when
* the two markers on that coordinate can be told apart.
*/
stub(node: Element) {
const existing = clusterMock.markers.get(node)
if (existing) return existing
const fresh = { pin: node }
clusterMock.markers.set(node, fresh)
return fresh
},
group: {
getVisibleParent(marker: unknown) {
clusterMock.asked.push(marker)
return clusterMock.stacked ? { spiderfy } : marker
},
},
}
})
vi.mock('react-leaflet', () => ({
// center/zoom are surfaced so tests can assert the camera the map is built
// with; maxZoom because a cluster refuses to attach to a map without one.
@@ -63,9 +94,16 @@ vi.mock('react-leaflet', () => ({
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>{children}</div>
),
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
<div
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
ref={node => {
if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } })
// Stands in for the L.Marker react-leaflet hands back, so MapView's registry has
// something to look a selected stop up by. Only the markers the cluster group
// holds get one: a place pin is all this double stands in for, and the route-via
// markers outside the group expect a Leaflet marker of their own.
ref?.(node?.closest('[data-testid="cluster-group"]') ? clusterMock.stub(node) : null)
}}
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
@@ -118,8 +156,8 @@ vi.mock('react-leaflet', () => ({
vi.mock('react-leaflet-cluster', () => ({
// The real cluster group calls iconCreateFunction itself; the probe button
// lets a test invoke it with a chosen child count.
default: ({ children, iconCreateFunction }: any) => (
<div data-testid="cluster-group">
default: ({ children, iconCreateFunction, ref }: any) => (
<div data-testid="cluster-group" ref={node => { ref?.(node ? clusterMock.group : null) }}>
<button
data-testid="cluster-icon-probe"
onClick={(e: any) => {
@@ -174,6 +212,9 @@ const ORIGINAL_WIDTH = window.innerWidth
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
clusterMock.markers.clear()
clusterMock.asked.length = 0
clusterMock.stacked = false
mapMock.panes.clear()
thumbCallbacks.clear()
geoMock.position = null
@@ -558,6 +599,32 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-082: a booking switched on for another day keeps drawing unless the map is held to the day', () => {
// The desktop passes no scope, so a booking switched on stays drawn on every day.
// Only a caller that asks for it drops the booking from a day it does not run on.
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const reservation = buildReservation({
id: 44,
type: 'flight',
day_id: 10,
end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
})
const reservations = [reservation]
const ids = [44]
const { rerender } = render(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />)
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={10} scopeConnectionsToDay />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => {
// Icon components come from a lookup table a plugin can extend; one that
// throws must not take the whole map down, only its own glyph.
@@ -879,6 +946,14 @@ describe('MapView selection panning (#921)', () => {
expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true })
})
it('FE-COMP-MAPVIEW-083: pans to a selected stay whose pin a filter keeps off the map', () => {
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
const stay = buildMapPlace({ id: 11, lat: 50, lng: 4 })
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
rerender(<MapView places={places} selectedPlaceId={11} selectedPlace={stay} />)
expect(mapMock.panTo).toHaveBeenCalledWith([50, 4], { animate: true })
})
it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => {
const places = [buildMapPlace({ id: 10, lat: null, lng: null })]
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
@@ -988,6 +1063,42 @@ describe('MapView live location', () => {
fireEvent.click(screen.getByRole('button', { name: 'Follow my location' }))
expect(geoMock.cycleMode).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
geoMock.mode = 'off'
render(<MapView />)
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
// landscape crosses the 768px cut without remounting the map
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none.
expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull()
desktop.unmount()
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
const { container } = render(<MapView />)
// The phone used to get an (i) here that opened the credit through a class on the
// wrapper. The phone map now carries no visible credit at all (mobile.css), so neither
// the button nor the class it toggled is left behind.
expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull()
const wrapper = container.querySelector('div.w-full.h-full.relative') as HTMLElement
expect(wrapper.classList.contains('m-attrib-open')).toBe(false)
})
})
describe('MapView bounds fitting', () => {
@@ -1048,6 +1159,50 @@ describe('MapView bounds fitting', () => {
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-078: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', () => {
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
// the bottom. The flat margin put the ends of a framed leg under either of them.
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
const { rerender } = render(<MapView places={[]} fitKey={1} />)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
expect(mapMock.fitBounds.mock.calls[0][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 290] })
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 }), buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 })]
rerender(<MapView places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
// And without one, the focus fit falls back to that margin too.
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
rerender(<MapView places={places} fitKey={2} focusPoints={stage} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(3)
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
})
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
// A parent that builds the object inline hands a new one on every render. The camera
// belongs to the traveller between fits, so that alone must not take it back.
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
// New numbers mean the chrome moved, so the same points are framed again around it.
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 98] })
})
})
describe('MapView photo thumbnails', () => {
@@ -1120,6 +1275,31 @@ describe('MapView photo thumbnails', () => {
expect(thumbCallbacks.size).toBe(0)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-080: an uploaded photo fills its marker whatever its proportions', () => {
// Leaflet's marker pane sets `width: auto` on every img in it, so a photo sized
// by attributes was drawn at its full pixel size and the circle only ever showed
// the transparent corner of it, over the category colour.
render(<MapView places={[buildMapPlace({ id: 27, lat: 48, lng: 2, image_url: '/uploads/places/wide.jpg' })]} />)
const holder = document.createElement('div')
holder.innerHTML = iconHtmlOf(screen.getAllByTestId('marker')[0])
const img = holder.querySelector('img')!
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
expect(img.style.width).toBe('100%')
expect(img.style.height).toBe('100%')
})
it('FE-COMP-MAPVIEW-081: taking the upload off a place asks for its auto photo again, without a reload', () => {
const place = buildMapPlace({ id: 28, lat: 48, lng: 2, google_place_id: 'gp-28', name: 'Tower', image_url: '/uploads/places/own.jpg' })
const { rerender } = render(<MapView places={[place]} />)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
rerender(<MapView places={[{ ...place, image_url: null }]} />)
expect(vi.mocked(photoService.fetchPhoto)).toHaveBeenCalledWith('gp-28', 'gp-28', 48, 2, 'Tower')
expect(thumbCallbacks.has('gp-28')).toBe(true)
})
})
// The marker HTML is a hand-built string handed to L.divIcon, i.e. innerHTML.
@@ -1157,3 +1337,54 @@ describe('MapView — untrusted values in the marker HTML', () => {
expect(iconHtml()).toContain('#00ff00')
})
})
// Stops modelling one building — drop the bags, check in, the museum inside it — all
// carry the same coordinates, so only the top pin of the pile is ever on screen. The
// places rail used to raise the chosen one with a z-index; inside a bubble there is no
// pin to raise, so the bubble has to open instead (#2344).
describe('MapView — picking a stop that shares its coordinates', () => {
const hotel = { lat: 48.8584, lng: 2.2945 }
const stacked = [
buildMapPlace({ id: 1, name: 'Drop the bags', ...hotel }),
buildMapPlace({ id: 2, name: 'Check in', ...hotel }),
buildMapPlace({ id: 3, name: 'Louvre', lat: 48.8606, lng: 2.3376 }),
]
/** The place pins, in the order the places were handed over. */
const pins = () => screen.getAllByTestId('marker').filter(node => node.closest('[data-testid="cluster-group"]'))
const markerOf = (index: number) => clusterMock.markers.get(pins()[index])
it('FE-COMP-MAPVIEW-077: the bubble it is hiding in is fanned open', () => {
clusterMock.stacked = true
const { rerender } = render(<MapView places={stacked} />)
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
rerender(<MapView places={stacked} selectedPlaceId={2} />)
expect(clusterMock.spiderfy).toHaveBeenCalled()
// And it asked about the stop that was picked — not about the one stacked under it,
// which carries the same coordinates, and not about whichever registered first.
expect(clusterMock.asked).toContain(markerOf(1))
expect(clusterMock.asked).not.toContain(markerOf(0))
expect(clusterMock.asked).not.toContain(markerOf(2))
})
it('FE-COMP-MAPVIEW-079: a stop in another pile is looked up by its own id', () => {
clusterMock.stacked = true
const { rerender } = render(<MapView places={stacked} />)
rerender(<MapView places={stacked} selectedPlaceId={3} />)
expect(clusterMock.asked).toContain(markerOf(2))
expect(clusterMock.asked).not.toContain(markerOf(0))
expect(clusterMock.asked).not.toContain(markerOf(1))
})
it('FE-COMP-MAPVIEW-078: a stop with a pin of its own is left alone, camera included', () => {
clusterMock.stacked = false
const { rerender } = render(<MapView places={stacked} />)
rerender(<MapView places={stacked} selectedPlaceId={1} />)
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
// The selection still only pans: the zoom belongs to the day fit.
expect(mapMock.panTo).toHaveBeenCalled()
expect(mapMock.setView).not.toHaveBeenCalled()
})
})
+102 -48
View File
@@ -4,6 +4,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
@@ -61,7 +62,7 @@ import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -194,7 +195,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
">
<img src="${escapeHtml(place.image_url)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(place.image_url)}
</div>
${badgeHtml}
</div>`,
@@ -329,10 +330,11 @@ interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
selectedPlace: Place | null
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
@@ -342,7 +344,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = places.find(p => p.id === selectedPlaceId)
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
@@ -356,7 +358,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
return null
}
@@ -399,15 +401,33 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
* margin. A shell that lays its own bars over the map passes what they cover, so the
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
function leafletPadding(box: ViewportPadding): L.FitBoundsOptions {
return {
paddingTopLeft: [box.left, box.top],
paddingBottomRight: [box.right, box.bottom],
}
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
const fitRan = useRef(false)
const fitTo = useCallback((coords: [number, number][]) => {
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
@@ -426,7 +446,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
* (#1982). MapViewGL never had the nudge, so this also brings the two
* renderers back into agreement.
*/
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
}
} catch {}
}, [map, paddingOpts])
@@ -458,14 +478,18 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
// The caller's padding as a value, so a parent that builds the object inline on every
// render does not move the camera each time it renders.
const fitPaddingKey = fitPadding ? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ') : ''
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints)
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -520,15 +544,17 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
@@ -606,16 +632,25 @@ interface MemoMarkerProps {
onHoverOut: () => void
/** Off in read-only trips and on the phone, where HTML5 drag does not exist. */
draggable: boolean
/** Hands the Leaflet marker up, so a selection can find the bubble it is hiding in. */
onRegister: (id: number, marker: unknown) => void
}
const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable,
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
const cleanupRef = useRef<(() => void) | null>(null)
// react-leaflet compares `position` by reference and calls setLatLng whenever it
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
position={[place.lat, place.lng]}
ref={register}
position={position}
icon={icon}
eventHandlers={{
// The element only exists once Leaflet has put the marker on the map,
@@ -645,6 +680,8 @@ export const MapView = memo(function MapView({
routeColors = null,
routeSegments = [],
selectedPlaceId = null,
// The selected place itself, for when no pin on this map stands for it.
selectedPlace = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
@@ -666,6 +703,9 @@ export const MapView = memo(function MapView({
showTransitRoutes = true,
days = [] as Day[],
selectedDayId = null,
// Whether a booking switched on by hand also has to run on the selected day to be
// drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
scopeConnectionsToDay = false,
onReservationClick,
pois = [] as Poi[],
onPoiClick,
@@ -685,6 +725,7 @@ export const MapView = memo(function MapView({
onRemoveVia,
alternativeRoutes,
focusPoints,
fitPadding,
clusterLoosely = false,
activeAlternative,
onChooseAlternative,
@@ -728,8 +769,8 @@ export const MapView = memo(function MapView({
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
@@ -746,10 +787,7 @@ export const MapView = memo(function MapView({
}
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
const paddingOpts = useMemo((): L.FitBoundsOptions => ({
paddingTopLeft: [paddingBox.left, paddingBox.top],
paddingBottomRight: [paddingBox.right, paddingBox.bottom],
}), [paddingBox])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
// Japan straight away instead of the world view followed by a flight across the planet.
@@ -817,7 +855,7 @@ export const MapView = memo(function MapView({
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const photoSources = useMemo(() => photoSourcesKey(places), [places])
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
@@ -874,23 +912,46 @@ export const MapView = memo(function MapView({
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}, [])
}, [photoSources, placesPhotosEnabled])
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
// Drag a marker onto a day (#891). Pointer-driven, so it is off wherever
// HTML5 drag does not exist — and the day plan is not on screen there anyway.
const markersDraggable = !isTouchDevice
/**
* Reaching a stop that shares its coordinates with others.
*
* Such a stop has no pin of its own while the stack is collapsed — it is inside a
* cluster bubble — so the z-index that used to lift the selected pin out of the pile
* has nothing to lift. Fan the bubble open instead, which leaves the camera alone
* (#2344). The registry is what lets a selection find its Leaflet marker at all.
*/
const clusterGroupRef = useRef<ClusterGroupLike | null>(null)
const placeMarkersRef = useRef(new Map<number, unknown>())
const registerClusterGroup = useCallback((group: unknown) => {
clusterGroupRef.current = (group as ClusterGroupLike | null) ?? null
}, [])
const registerMarker = useCallback((id: number, marker: unknown) => {
if (marker) placeMarkersRef.current.set(id, marker)
else placeMarkersRef.current.delete(id)
}, [])
useEffect(() => {
if (selectedPlaceId == null) return
let frame: number | null = null
const reveal = () => {
frame = null
revealInCluster(clusterGroupRef.current, placeMarkersRef.current.get(selectedPlaceId))
}
reveal()
// The group takes its markers in on a microtask and adds them in chunks, so a
// selection that arrives with the map — a link straight into a day — finds nothing
// on the first pass. A second attempt one frame later does.
if (typeof requestAnimationFrame === 'function') frame = requestAnimationFrame(reveal)
return () => { if (frame !== null) cancelAnimationFrame(frame) }
}, [selectedPlaceId, places])
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = photoCacheKey(place)
@@ -908,9 +969,10 @@ export const MapView = memo(function MapView({
onHover={handleMarkerHover}
onHoverOut={handleMarkerHoverOut}
draggable={markersDraggable}
onRegister={registerMarker}
/>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut, markersDraggable])
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut, markersDraggable, registerMarker])
// Parsing track geometry is the expensive part (tracks run to tens of thousands
// of points), so it hangs off `places` alone — a selection change must not
@@ -979,7 +1041,10 @@ export const MapView = memo(function MapView({
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// The width is followed rather than read once: a phone turned sideways and
// back crosses the breakpoint twice, and the button used to stay with
// whatever the map saw when it mounted.
const isMobile = useIsPhone()
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
@@ -1068,8 +1133,8 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1077,18 +1142,7 @@ export const MapView = memo(function MapView({
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={20}
disableClusteringAtZoom={9}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
<MarkerClusterGroup ref={registerClusterGroup} {...CLUSTER_OPTIONS} iconCreateFunction={createClusterIcon}>
{markers}
</MarkerClusterGroup>
@@ -1242,7 +1296,7 @@ export const MapView = memo(function MapView({
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
+553 -3
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render } from '../../../tests/helpers/render'
import { act, waitFor } from '@testing-library/react'
import { act, fireEvent, waitFor } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { server } from '../../../tests/helpers/msw/server'
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import maplibregl from 'maplibre-gl'
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import type { PluginMapLayer, PluginMapLayerFeature, PluginMapMarker } from '../../api/client'
import type { Poi } from './poiCategories'
@@ -254,6 +255,12 @@ beforeEach(() => {
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'))
glMap.getBearing.mockReturnValue(0)
glMap.queryTerrainElevation.mockReturnValue(null)
// A busy style, a refused source and a custom layer list are each set by one test
// for the case it covers. Put back here, or every test after it would silently run
// against a map that never settles.
glMap.isStyleLoaded.mockReturnValue(true)
glMap.getStyle.mockReturnValue({ layers: [] })
glMap.addSource.mockImplementation(() => undefined)
glMarkers.clear()
// clearAllMocks() wipes call history but keeps implementations, so anything a
// test overrides with mockReturnValue has to be put back here.
@@ -863,6 +870,17 @@ describe('MapViewGL', () => {
properties: Record<string, unknown>
geometry: { type: string; coordinates: number[] | number[][] | number[][][] }
}
// A projection for the tests that care how far apart two pins land on screen. The map
// double projects 10 px per degree, an overview so coarse that a whole city sits under
// one pin; a real map is around 1500 px per degree at the zoom a day is fitted to.
// Setting an implementation at all is also what puts project() back: an earlier test
// pins it to a single point, and clearAllMocks keeps an implementation once set.
const PX_PER_DEGREE = 1500
const projectAtDayZoom = () =>
glMap.project.mockImplementation((lngLat: [number, number]) => (
{ x: lngLat[0] * PX_PER_DEGREE, y: lngLat[1] * PX_PER_DEGREE }
))
const geoSource = () => ({ setData: vi.fn((_data: unknown) => {}) })
function lastData(src: ReturnType<typeof geoSource>): { features: GeoFeature[] } {
const calls = vi.mocked(src.setData).mock.calls
@@ -1361,6 +1379,33 @@ describe('MapViewGL', () => {
expect(onReservationClick).toHaveBeenCalledWith(77)
})
it('FE-COMP-MAPVIEWGL-087: a booking switched on for another day reaches the overlay unless the map is held to the day', async () => {
loadOnAttach()
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const flight = {
id: 44, trip_id: 1, type: 'flight', title: 'A → B', status: 'confirmed', day_id: 10, end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
}
const reservations = [flight]
const ids = [44]
const drawn = () => (reservationOverlay.update.mock.lastCall?.[0] as { id: number }[]).map(r => r.id)
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />,
)
await act(async () => {})
expect(drawn()).toEqual([44])
rerender(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />,
)
await act(async () => {})
expect(drawn()).toEqual([])
})
it('FE-COMP-MAPVIEWGL-042: explore POIs get their own pin, hover popup and click callback', async () => {
loadOnAttach()
const basePoi: Poi = {
@@ -1527,6 +1572,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-047: only the unclustered leaves of the cluster source get a rich marker (#1385)', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
@@ -1558,6 +1604,102 @@ describe('MapViewGL', () => {
expect(drawn).not.toContainEqual([2.3, 48.3])
})
// One building modelled as several stops — drop the bags, check in, the museum inside
// it — puts every pin on the same spot, and all but the top one are unreachable. There
// is no spiderfy on a GL map, so the stack draws as the one pin worth seeing (#2344).
it('FE-COMP-MAPVIEWGL-077: stops on one coordinate draw one pin, and picking a buried one makes it that pin', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 71 } },
{ properties: { placeId: 72 } },
{ properties: { placeId: 73 } },
])
const hotel = { lat: 48.8584, lng: 2.2945 }
const places = [
buildMapPlace({ id: 71, name: 'Drop the bags', ...hotel }),
buildMapPlace({ id: 72, name: 'Check in', ...hotel }),
buildMapPlace({ id: 73, name: 'Louvre', lat: 60.1, lng: 9.3 }),
]
// The order badge is what tells the two stops on the hotel apart on screen.
const dayOrderMap = { 71: [1], 72: [2], 73: [3] }
const pinsOnTheHotel = () => glMarkers.created.filter(marker => marker.lngLat?.[0] === hotel.lng)
const { rerender } = render(<MapViewGL places={places} fitKey={1} dayOrderMap={dayOrderMap} />)
await flushFrames()
expect(pinsOnTheHotel()).toHaveLength(1)
expect(pinsOnTheHotel()[0].element.innerHTML).toContain('>1</span>')
// The stop elsewhere is untouched by the folding.
expect(glMarkers.created.map(m => m.lngLat)).toContainEqual([9.3, 60.1])
glMarkers.clear()
rerender(<MapViewGL places={places} fitKey={1} dayOrderMap={dayOrderMap} selectedPlaceId={72} />)
await flushFrames()
expect(pinsOnTheHotel()).toHaveLength(1)
expect(pinsOnTheHotel()[0].element.innerHTML).toContain('>2</span>')
})
// Two ordinary neighbours — a hotel and the restaurant across the street — are not a
// pile. Nothing here can fan a bubble open again, so anything the fold reaches beyond
// the pins that cover each other is a stop the map simply loses (#2344).
it('FE-COMP-MAPVIEWGL-078: stops a few hundred metres apart keep a pin each', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 81 } },
{ properties: { placeId: 82 } },
])
// ~300 m apart at this latitude, which the projection above puts 6 px apart: well
// inside the width of a pin, and still two stops a user has to be able to reach.
const places = [
buildMapPlace({ id: 81, name: 'Hotel Le Marais', lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 82, name: 'Chez Julien', lat: 48.8584, lng: 2.2986 }),
]
render(<MapViewGL places={places} fitKey={1} />)
await flushFrames()
const drawn = glMarkers.created.map(m => m.lngLat)
expect(drawn).toContainEqual([2.2945, 48.8584])
expect(drawn).toContainEqual([2.2986, 48.8584])
})
// Without a projection there is no telling which pins land on each other, and a guess
// costs a stop its marker for good. Draw them all instead, as the map always did.
it('FE-COMP-MAPVIEWGL-079: a map that cannot place its pins still draws every one of them', async () => {
loadOnAttach()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 91 } },
{ properties: { placeId: 92 } },
])
const places = [
buildMapPlace({ id: 91, lat: 48.1, lng: 2.1 }),
buildMapPlace({ id: 92, lat: 48.2, lng: 2.2 }),
]
const projection = glMap.project
const engine = glMap as unknown as { project?: unknown }
try {
engine.project = undefined
render(<MapViewGL places={places} fitKey={1} />)
await flushFrames()
} finally {
engine.project = projection
}
const drawn = glMarkers.created.map(m => m.lngLat)
expect(drawn).toContainEqual([2.1, 48.1])
expect(drawn).toContainEqual([2.2, 48.2])
})
it('FE-COMP-MAPVIEWGL-048: the day route becomes one LineString per drawn segment', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
@@ -1728,7 +1870,7 @@ describe('MapViewGL', () => {
expect(srcs()).toContain('data:image/png;base64,BBB')
})
it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and the proxy url is used as the id', async () => {
it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and a picked proxy url is both id and cache key', async () => {
vi.mocked(photoService.isLoading).mockImplementation((key: string) => key === 'osm-loading')
const places = [
buildMapPlace({ id: 94, lat: 48.4, lng: 2.4, osm_id: 'osm-loading' }),
@@ -1739,7 +1881,56 @@ describe('MapViewGL', () => {
await act(async () => {})
expect(photoService.fetchPhoto).toHaveBeenCalledTimes(1)
expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
expect(photoService.fetchPhoto).toHaveBeenCalledWith('/api/maps/place-photo/abc', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
})
it('FE-COMP-MAPVIEWGL-101: an uploaded photo fills its marker whatever its proportions', async () => {
// A phone photo is no 48px square like the thumbs. Sized by attributes it lost to
// the stylesheet's `height: auto` and sat as a sliver in a disc of category colour.
loadOnAttach()
render(<MapViewGL places={[buildMapPlace({ id: 14, lat: 48.14, lng: 2.14, image_url: '/uploads/places/wide.jpg' })]} fitKey={1} />)
await act(async () => {})
const img = glMarkers.created[0].element.querySelector('img')!
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
expect(img.style.width).toBe('100%')
expect(img.style.height).toBe('100%')
})
it('FE-COMP-MAPVIEWGL-102: taking the upload off a place asks for its auto photo again, without a reload', async () => {
const place = buildMapPlace({ id: 15, lat: 48.15, lng: 2.15, google_place_id: 'gp-15', name: 'Tower', image_url: '/uploads/places/own.jpg' })
const { rerender } = render(<MapViewGL places={[place]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
rerender(<MapViewGL places={[{ ...place, image_url: null }]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).toHaveBeenCalledWith('gp-15', 'gp-15', 48.15, 2.15, 'Tower')
})
it('FE-COMP-MAPVIEWGL-103: picking another suggested photo replaces the thumb of the old one', async () => {
loadOnAttach()
const first = '/api/maps/place-photo/gp-16~p0/bytes'
const second = '/api/maps/place-photo/gp-16~p1/bytes'
// Everything seen before the new pick has a thumb: the place's auto photo under
// its provider id and the first pick under its own url.
vi.mocked(photoService.getCached).mockImplementation((key: string) => (
key === 'gp-16' || key === first
? ({ thumbDataUrl: 'data:image/png;base64,OLD' } as unknown as ReturnType<typeof photoService.getCached>)
: undefined
))
const place = buildMapPlace({ id: 16, lat: 48.16, lng: 2.16, google_place_id: 'gp-16', name: 'Tower', image_url: first })
const { rerender } = render(<MapViewGL places={[place]} fitKey={1} />)
await flushFrames()
rerender(<MapViewGL places={[{ ...place, image_url: second }]} fitKey={1} />)
await flushFrames()
expect(photoService.fetchPhoto).toHaveBeenCalledWith(second, second, 48.16, 2.16, 'Tower')
// Until its thumb is ready the marker shows the new pick itself, not the old thumb.
const latest = glMarkers.created[glMarkers.created.length - 1]
expect(latest.element.querySelector('img')?.getAttribute('src')).toBe(second)
})
it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => {
@@ -2001,6 +2192,118 @@ describe('MapViewGL', () => {
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
// Both GL libraries take their clicks from the canvas container, and the handle sits
// inside it, right on the route's hit band. Stopping pointerdown does not stop the click
// that follows, so a click on a handle also read as a click on the road under it and
// dropped a second via exactly there.
it('FE-COMP-MAPVIEWGL-VIA-003: a click on a via handle is the handle\'s, not the road\'s', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-route-hit' ? { id } : null))
const onRouteClick = vi.fn()
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
onRouteClick={onRouteClick}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
const reachedMap = vi.fn()
glCanvasContainer.addEventListener('click', reachedMap)
try {
act(() => { handle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(reachedMap).not.toHaveBeenCalled()
} finally {
glCanvasContainer.removeEventListener('click', reachedMap)
}
// And the road's own handler leaves a click that came through the handle alone.
const roadClick = layerHandler('click', 'trip-route-hit')
expect(roadClick).toBeTypeOf('function')
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: handle } }) })
expect(onRouteClick).not.toHaveBeenCalled()
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: glCanvasContainer } }) })
expect(onRouteClick).toHaveBeenCalledWith(48.1, 2.1)
})
it('FE-COMP-MAPVIEWGL-VIA-004: a right-click on a via handle removes it and opens nothing underneath', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const onRemoveVia = vi.fn()
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
onRemoveVia={onRemoveVia}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
// The map's contextmenu is the add-place gesture; it must not fire for the spot the
// via has just been removed from.
const reachedMap = vi.fn()
glCanvasContainer.addEventListener('contextmenu', reachedMap)
try {
act(() => { handle.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) })
} finally {
glCanvasContainer.removeEventListener('contextmenu', reachedMap)
}
expect(onRemoveVia).toHaveBeenCalledWith(1, 5)
expect(reachedMap).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-VIA-005: the handles go away below the zoom a via can be aimed at', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
// Country scale: a day's vias collapse into a heap of dots over one town, and a
// short drag on one of them moves the route by kilometres.
vi.mocked(glMap.getZoom).mockReturnValue(8)
try {
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
expect(handle.style.display).toBe('none')
// Back in close enough, the zoom that settles brings them back.
vi.mocked(glMap.getZoom).mockReturnValue(12)
act(() => {
glMap.on.mock.calls
.filter(c => c[0] === 'zoomend' && typeof c[1] === 'function')
.forEach(c => (c[1] as () => void)())
})
expect(handle.style.display).toBe('block')
} finally {
vi.mocked(glMap.getZoom).mockReturnValue(10)
}
})
// ── Satellite ───────────────────────────────────────────────────────────────
//
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
@@ -2030,6 +2333,28 @@ describe('MapViewGL', () => {
)
})
it('FE-COMP-MAPVIEWGL-100: the imagery goes under the offered roads too, not just under the route', async () => {
// The roads offered for a leg are added before the route source so they sit beneath the
// current route, which also puts them before the first `trip-` layer. Anchoring the
// imagery on that first `trip-` layer therefore painted it straight over them, and an
// offered road on a satellite basemap could not be seen at all.
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({
layers: [{ id: 'background' }, { id: 'road' }, { id: 'route-alt-hit' }, { id: 'route-alt-line' }, { id: 'trip-route' }],
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'route-alt-hit',
)
})
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
loadOnAttach()
useSettingsStore.setState({
@@ -2065,4 +2390,229 @@ describe('MapViewGL', () => {
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
// A map is busy (isStyleLoaded() false) for as long as tiles or a setData are in flight,
// which on a phone is most of the time and on the road trip stage was every tap. The
// imagery used to wait for a settled style that `styledata` never announced.
it('FE-COMP-MAPVIEWGL-079: a stored satellite choice is drawn even while the map is still busy', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// Right after `load` the basemap tiles and the overlay sources are still loading.
glMap.isStyleLoaded.mockReturnValue(false)
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' }))
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-080: a tap on the switcher while a source update is in flight still shows the imagery', async () => {
loadOnAttach()
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
const { getByRole } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
glMap.isStyleLoaded.mockReturnValue(false)
fireEvent.click(getByRole('button', { name: 'Switch to satellite view' }))
await flushFrames()
expect(useSettingsStore.getState().settings.map_base_layer).toBe('satellite')
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' }))
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-081: imagery that is already on the map flips visibility at once, busy or not', async () => {
loadOnAttach()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
const { getByRole } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
glMap.setLayoutProperty.mockClear()
glMap.isStyleLoaded.mockReturnValue(false)
fireEvent.click(getByRole('button', { name: 'Switch to satellite view' }))
await flushFrames()
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'visible')
// Flipping it is all a second tap takes: nothing is built a second time.
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything())
})
it('FE-COMP-MAPVIEWGL-082: a style that is not in yet keeps the plain basemap until styledata brings it', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// What both engines do when the style document itself has not loaded.
let styleIn = false
glMap.addSource.mockImplementation((id: string) => {
if (id === 'trip-satellite' && !styleIn) throw new Error('Style is not done loading.')
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything())
styleIn = true
const subscribed = glMap.on.mock.calls.filter(c => c[0] === 'styledata')
const styledata = subscribed[subscribed.length - 1]?.[1] as (() => void) | undefined
expect(styledata).toBeDefined()
act(() => { styledata!() })
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-083: the switcher sits the shared inset off the map edge, past any side panel', async () => {
const { getByRole, rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
// Button, then its frosted shell, then the positioned wrapper.
const wrapper = () => getByRole('button', { name: 'Switch to satellite view' }).parentElement!.parentElement!
// The phone's compass is placed off the same inset, so the two cannot drift apart.
expect(wrapper().style.left).toBe(`${MAP_LAYER_SWITCHER_INSET}px`)
rerender(<MapViewGL places={[]} fitKey={1} leftWidth={240} />)
await flushFrames()
expect(wrapper().style.left).toBe(`${240 + MAP_LAYER_SWITCHER_INSET}px`)
})
it('FE-COMP-MAPVIEWGL-084: a source left behind without its layer still gets the layer', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// An earlier pass that got the source in and then failed on the layer.
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
// Guarded on the source, the pass would have seen it and built nothing, for good.
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-085: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', async () => {
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
// the bottom. The flat 40px margin put the ends of a framed leg under either of them.
const original = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
try {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
expect(glMap.fitBounds.mock.calls[0][1]).toMatchObject({ padding: chrome, maxZoom: 15 })
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
const places = [buildMapPlace({ id: 88, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 89, lat: 48.2, lng: 2.2 })]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
// And without one, the focus fit falls back to that margin too.
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={stage} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(3)
expect(glMap.fitBounds.mock.calls[2][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: original })
}
})
it('FE-COMP-MAPVIEWGL-086: the caller padding is read by value, so only new numbers refit the points on screen', async () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// A parent that builds the object inline hands a new one on every render. The camera
// belongs to the traveller between fits, so that alone must not take it back.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// New numbers mean the chrome moved, so the same points are framed again around it.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 120, right: 16, bottom: 98, left: 16 } })
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
/** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */
function compactAttribution(container: HTMLElement): HTMLElement {
const el = document.createElement('details')
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show'
el.setAttribute('open', '')
// Inside the map container itself, where maplibre puts its controls.
container.querySelectorAll('div')[1]?.appendChild(el)
return el
}
it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const attrib = compactAttribution(container)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
expect(idle).toBeDefined()
act(() => { idle!() })
// Both engines open theirs on a phone and only tuck it away on the first drag, so
// the first thing on a screen the map fills is a two-line band across the bottom.
expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false)
expect(attrib.hasAttribute('open')).toBe(false)
// Still there, still one tap away: nothing about the credit is removed.
expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true)
})
it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const el = document.createElement('details')
// No `compact-show`, which is what a map over 640px reports.
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib'
el.setAttribute('open', '')
container.querySelectorAll('div')[1]?.appendChild(el)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
act(() => { idle!() })
expect(el.hasAttribute('open')).toBe(true)
})
})
+154 -43
View File
@@ -9,10 +9,10 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -20,14 +20,15 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { escapeHtml } from '@trek/shared'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { groupCoincidentPlaces } from './coincidentPlaces'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { useDawarichTrailGL } from './useDawarichTrailGL'
@@ -40,7 +41,8 @@ import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -74,7 +76,11 @@ const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -142,6 +148,8 @@ interface Props {
routeColors?: ({ line: string; casing: string } | undefined)[] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
/** The selected place itself, for when no pin on this map stands for it. */
selectedPlace?: Place | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
@@ -159,6 +167,11 @@ interface Props {
showTransitRoutes?: boolean
days?: Day[]
selectedDayId?: number | null
/**
* Whether a booking switched on by hand also has to run on the selected day to be
* drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
*/
scopeConnectionsToDay?: boolean
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
@@ -185,6 +198,16 @@ interface Props {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* The default padding knows this component's panels and nothing else, and on a phone it
* is a flat margin. A shell that lays its own bars over the map passes what they cover,
* so the frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
/**
* Let markers stay apart longer than usual.
*
@@ -216,7 +239,15 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster. */
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
@@ -277,15 +308,30 @@ function addPlaceClusterLayers(map: any): void {
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
* a no-op and only the visibility flips.
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
try {
if (!map.isStyleLoaded?.()) return
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(SATELLITE_SOURCE_ID)) {
if (!on) return // Nothing to build while it is switched off.
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
@@ -293,21 +339,18 @@ function applySatellite(map: any, on: boolean): void {
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
})
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
@@ -380,7 +423,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
overflow:hidden;background:${bgColor};
box-sizing:content-box;
">
<img src="${escapeHtml(photoUrl)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(photoUrl)}
</div>
${badgeHtml}
`
@@ -636,6 +679,7 @@ export function MapViewGL({
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
selectedPlace = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
@@ -644,6 +688,7 @@ export function MapViewGL({
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
fitPadding,
clusterLoosely = false,
hazards,
dawarichTrack = null,
@@ -659,6 +704,7 @@ export function MapViewGL({
showTransitRoutes = true,
days = NO_DAYS,
selectedDayId = null,
scopeConnectionsToDay = false,
showReservationStats = false,
onReservationClick,
pois = NO_POIS,
@@ -810,15 +856,17 @@ export function MapViewGL({
const VIA_MIN_ZOOM = 9
const applyViaZoom = () => {
const on = map.getZoom() >= VIA_MIN_ZOOM
for (const pin of viaPinsRef.current) {
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
if (node) node.style.display = on ? '' : 'none'
}
// `el` is the one handle both pin paths expose; the library marker's own accessor
// does not exist on the hand-positioned pin, so reading that left every handle on.
for (const pin of viaPinsRef.current) pin.el.style.display = on ? 'block' : 'none'
}
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
// Marks the handle for the map's own click handlers: the drive's hit band runs
// right under it, and a click that lands here is the handle's, not the road's.
el.dataset.viaHandle = ''
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
viaPinsRef.current.push(pin)
if (!viasDraggable) continue
@@ -883,6 +931,10 @@ export function MapViewGL({
}
const onContext = (e: MouseEvent) => {
e.preventDefault()
// Stopped here as well: both GL libraries listen on the canvas container the
// handle sits in, and the map's own contextmenu opens the add-place form at
// the very spot the via has just been removed from.
e.stopPropagation()
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
}
el.addEventListener('pointerdown', onDown)
@@ -893,6 +945,10 @@ export function MapViewGL({
el.addEventListener('mousedown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('dblclick', swallow)
// Stopping pointerdown does not stop the click that follows it, and the handle sits
// on the route's hit band: the map's click landed on the road and dropped a second
// via exactly under the one that was clicked.
el.addEventListener('click', swallow)
viaCleanupRef.current.push(() => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
@@ -902,6 +958,7 @@ export function MapViewGL({
el.removeEventListener('mousedown', swallow)
el.removeEventListener('touchstart', swallow)
el.removeEventListener('dblclick', swallow)
el.removeEventListener('click', swallow)
})
}
@@ -1037,8 +1094,19 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onRouteClick) return
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
const onClick = (e: {
lngLat: { lat: number; lng: number }
point?: { x: number; y: number }
originalEvent?: { target?: EventTarget | null }
preventDefault?: () => void
}) => {
e.preventDefault?.()
// A layer handler only asks what is drawn under the point, not what element the
// click came through. A via handle sits on the band by definition, and a click on
// it must not read as a click on the road under it; the map-level handler below
// filters markers the same way.
const target = e.originalEvent?.target
if (target instanceof Element && target.closest('[data-via-handle], .mapboxgl-marker, .maplibregl-marker')) return
// Layer handlers are independent: MapLibre evaluates each registration
// against the same click, so a point that hits both the offered-route band
// and the current-route band fires both. Every alternative starts and ends
@@ -1102,6 +1170,12 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
// the location button would be misleading; the phone, where real GPS lives,
// keeps it. Read here with the other hooks rather than beside the button:
// the tokenless branch below returns early, and a hook after it runs on some
// renders and not others.
const isMobile = useIsPhone()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
@@ -1170,6 +1244,23 @@ export function MapViewGL({
maxWidth: '240px',
className: 'trek-map-popup',
})
/*
* The credit starts as the little (i), not as a ribbon across the map.
*
* Both engines collapse their attribution below 640px, and both then open it
* anyway and wait for a drag before tucking it away. On a phone, where the map IS
* the screen, that means the first thing anybody sees is a two-line grey band
* over the bottom of it. Dragging is exactly what maplibre does on its own
* `drag` handler; this only starts where that would have ended up, so the credit
* is one tap away and nothing about it is removed.
*/
map.once('idle', () => {
const attrib = containerRef.current?.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show')
?? containerRef.current?.querySelector('.mapboxgl-ctrl-attrib.mapboxgl-compact-show')
attrib?.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show')
attrib?.removeAttribute('open')
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
@@ -1676,7 +1767,7 @@ export function MapViewGL({
// simultaneous thumb arrivals into one re-render.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const photoSources = useMemo(() => photoSourcesKey(places), [places])
useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
@@ -1727,7 +1818,7 @@ export function MapViewGL({
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
}, [photoSources, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
// Reconcile markers with places + photos. The clustered GeoJSON source decides
// which points are currently unclustered, and we render the existing rich HTML
@@ -1829,7 +1920,24 @@ export function MapViewGL({
seen.add(id)
visiblePlaces.push(place)
}
reconcileMarkers(visiblePlaces)
// Without a projection there is no way to tell which pins land on each other, so
// nothing is folded and every stop keeps the pin it has always had.
if (typeof map.project !== 'function') {
reconcileMarkers(visiblePlaces)
return
}
// Above the cluster cutoff supercluster reports every point unclustered, so stops
// on one coordinate would each get a pin and all but one of them would be buried
// (#2344). There is no spiderfy on a GL map to fan them out, so the pile draws as
// the one pin that is wanted: the selected stop when it is in there, which is what
// makes picking it from the places rail visible at all. Only pins that sit on each
// other fold — this side cannot undo a fold, hence its own tight radius.
const stacks = groupCoincidentPlaces(
visiblePlaces,
place => map.project([place.lng, place.lat]),
selectedPlaceId,
)
reconcileMarkers(stacks.map(stack => stack.lead))
}
const scheduleReconcile = () => {
if (raf !== null) return
@@ -2088,8 +2196,8 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
@@ -2177,6 +2285,10 @@ export function MapViewGL({
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// The caller's padding as a value, so a parent that builds the object inline on every
// render does not move the camera each time it renders.
const fitPaddingKey = fitPadding ? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ') : ''
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
@@ -2188,19 +2300,19 @@ export function MapViewGL({
pendingRouteFitRef.current = null
try {
map.fitBounds(bounds, {
padding: paddingOpts,
padding: fitPadding ?? paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId) return
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return
try {
map.flyTo({
@@ -2264,7 +2376,9 @@ export function MapViewGL({
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
// and reloading into the other keeps the choice. `styledata` is subscribed because a
// basemap change rebuilds the style from scratch and takes the imagery with it.
// basemap change rebuilds the style from scratch and takes the imagery with it. It is
// the retry for a style that is not in yet, never for a busy one: it fires on style
// edits, not when tiles arrive, which is why applySatellite does not wait for those.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
@@ -2285,9 +2399,6 @@ export function MapViewGL({
)
}
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
@@ -2314,7 +2425,7 @@ export function MapViewGL({
card too, and used to send the pill to the top of the map for a corner it never
covers. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
@@ -13,6 +13,10 @@ import {
parsePluginProfile,
withHotelBookends,
calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
} from './RouteCalculator'
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
@@ -975,3 +979,270 @@ describe('calculateAlternatives', () => {
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
})
})
// ── turning round at a stop ────────────────────────────────────────────────────
/**
* Gouffre de Padirac snaps onto `Route du Puits au Salvage`, which leads nowhere else.
* OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked, and it
* refuses the ENTIRE request when it cannot obey, so a road-trip day lost every leg it
* had over one cave car park, while each of its pairs routed perfectly on its own.
*/
describe('turning round at a stop', () => {
const askedFor = async (
call: (points: { lat: number; lng: number }[]) => Promise<unknown>,
count: number,
): Promise<string> => {
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await call(freshWaypoints(count))
return asked
}
it('FE-COMP-ROUTECALCULATOR-055: a drive that stops on the way may turn round where it stopped', async () => {
expect(await askedFor(p => calculateRoute(p), 3)).toContain('continue_straight=false')
expect(await askedFor(p => calculateRouteWithLegs(p), 3)).toContain('continue_straight=false')
expect(await askedFor(p => calculateSegments(p), 3)).toContain('continue_straight=false')
})
it('FE-COMP-ROUTECALCULATOR-056: two points have no middle, so the request is the one it always was', async () => {
expect(await askedFor(p => calculateRoute(p), 2)).not.toContain('continue_straight')
expect(await askedFor(p => calculateRouteWithLegs(p), 2)).not.toContain('continue_straight')
expect(await askedFor(p => calculateSegments(p), 2)).not.toContain('continue_straight')
})
it('FE-COMP-ROUTECALCULATOR-057: a router that has never heard of the permission is asked without it, once and then always', async () => {
// OSRM refuses an unknown parameter outright instead of ignoring it, and says which
// it is refusing: `InvalidQuery` names the query string, `NoRoute` names the road.
// Without this a router that predates the parameter would refuse every drive with a
// stop in the middle, which is a far worse fault than the one it fixes.
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://old-osrm.example.org' } }))
const asked: string[] = []
server.use(http.get('https://old-osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked.push(request.url)
return new URL(request.url).searchParams.has('continue_straight')
? HttpResponse.json({ code: 'InvalidQuery', message: 'Query string malformed' }, { status: 400 })
: HttpResponse.json(buildOsrmRouteResponse())
}))
const first = await calculateRoute(freshWaypoints(3))
expect(first.coordinates).toHaveLength(2)
expect(asked).toHaveLength(2)
expect(asked[1]).not.toContain('continue_straight')
// Remembered per host, so the second drive costs one request rather than two.
await calculateRoute(freshWaypoints(3))
expect(asked).toHaveLength(3)
expect(asked[2]).not.toContain('continue_straight')
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
it('FE-COMP-ROUTECALCULATOR-058: a refusal about the road is still a refusal, not a second request', async () => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://noroute-osrm.example.org' } }))
let calls = 0
server.use(http.get('https://noroute-osrm.example.org/route/v1/driving/:coords', () => {
calls += 1
return HttpResponse.json({ code: 'NoRoute', message: 'No route found between points' }, { status: 400 })
}))
await expect(calculateRoute(freshWaypoints(3))).rejects.toThrow()
expect(calls).toBe(1)
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
})
// ── a trip that avoids something ──────────────────────────────────────────────
/**
* The rail drives a trip with avoided classes through Valhalla, and everything that talks
* about one of its legs has to use the same engine. The offers used to come from OSRM with
* nothing avoided, so the picker described a different drive from the rail's; and an OSRM
* stand-in for a Valhalla that did not answer was filed as the avoided road for the rest
* of the session, without a word on the rail.
*/
describe('a trip that avoids something', () => {
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
/** Polyline6, the way Valhalla sends its shapes. */
const encode6 = (points: [number, number][]): string => {
const part = (value: number): string => {
let n = value < 0 ? ~(value << 1) : value << 1
let out = ''
while (n >= 0x20) { out += String.fromCharCode((0x20 | (n & 0x1f)) + 63); n >>= 5 }
return out + String.fromCharCode(n + 63)
}
let lat = 0
let lng = 0
return points.map(([pLat, pLng]) => {
const a = Math.round(pLat * 1e6)
const b = Math.round(pLng * 1e6)
const piece = part(a - lat) + part(b - lng)
lat = a
lng = b
return piece
}).join('')
}
const trip = (points: [number, number][], summary: Record<string, unknown> = {}) => ({
legs: [{ shape: encode6(points) }],
summary: { length: 100, time: 4000, has_toll: false, has_highway: false, has_ferry: false, ...summary },
})
const NORTH: [number, number][] = [[61, 10], [61.5, 11], [61, 12]]
const SOUTH: [number, number][] = [[61, 10], [60.5, 11], [61, 12]]
const DIRECT: [number, number][] = [[61, 10], [61, 11], [61, 12]]
const from = { lat: 61, lng: 10 }
const to = { lat: 61, lng: 12 }
it('FE-COMP-ROUTECALCULATOR-059: the ways come from Valhalla with the trip classes, and OSRM is never asked', async () => {
let osrm = 0
const bodies: Array<Record<string, unknown>> = []
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({
trip: trip(DIRECT, { time: 3600 }),
alternates: [{ trip: trip(NORTH, { time: 4200, has_ferry: true }) }, { trip: trip(SOUTH, { time: 4800 }) }],
})
}),
)
const routes = await calculateAlternatives(from, to, 'driving', { avoid: ['toll'] })
expect(osrm).toBe(0)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ alternates: 2, costing_options: { auto: { use_tolls: 0 } } })
expect(bodies[0].locations).toHaveLength(2)
expect(routes.map(r => r.engine)).toEqual(['valhalla', 'valhalla', 'valhalla'])
expect(routes.map(r => r.duration)).toEqual([3600, 4200, 4800])
expect(routes.map(r => r.hasFerry)).toEqual([false, true, false])
// Pinnable like any other offer: measured against the preferred one.
expect(routes[0].divergence).toBeNull()
expect(routes[1].divergence).toEqual({ lat: 61.5, lng: 11 })
})
it('FE-COMP-ROUTECALCULATOR-060: a Valhalla with nothing to say is the router not answering, not the OSRM roads', async () => {
let osrm = 0
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, () => new HttpResponse(null, { status: 503 })),
)
await expect(calculateAlternatives({ lat: 62, lng: 10 }, { lat: 62, lng: 12 }, 'driving', { avoid: ['ferry'] })).rejects.toThrow()
expect(osrm).toBe(0)
})
it('FE-COMP-ROUTECALCULATOR-061: one way back is offered a second only for a class the trip does not avoid yet', async () => {
const asked: Array<Record<string, number>> = []
server.use(http.post(VALHALLA, async ({ request }) => {
const body = await request.json() as { alternates?: number; costing_options: { auto: Record<string, number> } }
asked.push(body.costing_options.auto)
if (body.alternates) return HttpResponse.json({ trip: trip(DIRECT, { has_toll: true }) })
// Asked to leave out the tolls as well: a road that really is without them.
return HttpResponse.json({ trip: trip(NORTH, { time: 5000, has_toll: false }) })
}))
const routes = await calculateAlternatives({ lat: 61, lng: 10.5 }, { lat: 61, lng: 12 }, 'driving', { avoid: ['motorway'] })
// Motorways are avoided already, so the first extra question is about tolls, and it
// keeps the trip's own class in.
expect(asked).toEqual([{ use_highways: 0 }, { use_highways: 0, use_tolls: 0 }])
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'toll', engine: 'valhalla', duration: 5000 })
})
it('FE-COMP-ROUTECALCULATOR-062: only a driving leg is weighed; a walk is asked of OSRM as before', async () => {
let valhalla = 0
server.use(
http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 61], [12, 61]] }, distance: 1000, duration: 900 }] })),
http.post(VALHALLA, () => { valhalla++; return HttpResponse.json({ trip: trip(DIRECT) }) }),
)
const routes = await calculateAlternatives(from, to, 'walking', { avoid: ['toll'] })
expect(valhalla).toBe(0)
expect(routes).toHaveLength(1)
expect(routes[0].engine).toBeUndefined()
expect(avoidedClasses('walking', ['toll'])).toEqual([])
expect(avoidedClasses('driving', ['toll', 'ferry'])).toEqual(['ferry', 'toll'])
expect(routeEngineFor('driving', ['toll'])).toBe('valhalla')
expect(routeEngineFor('driving', [])).toBe('osrm')
expect(routeEngineFor('plugin:ev/fast', ['toll'])).toBe('plugin')
})
it('FE-COMP-ROUTECALCULATOR-063: an OSRM stand-in says nothing was avoided, and is not filed as the avoided road', async () => {
const [a, b] = freshWaypoints()
let osrm = 0
let valhallaUp = false
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildLegsResponse()) }),
http.post(VALHALLA, () => (valhallaUp
? HttpResponse.json({ trip: { legs: [{ shape: encode6([[a.lat, a.lng], [b.lat, b.lng]]), summary: { length: 5, time: 700, has_toll: false, has_ferry: true } }] } })
: new HttpResponse(null, { status: 503 }))),
)
const fallback = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(osrm).toBe(1)
expect(fallback.avoidance).toEqual({ asked: ['toll'], achieved: [], fellBack: true })
expect(fallback.distance).toBe(4200)
// Valhalla is back: the same drive asks it again instead of keeping the OSRM answer.
valhallaUp = true
const weighed = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(weighed.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] })
expect(weighed.distance).toBe(5000)
expect(weighed.hasFerry).toBe(true)
// The stand-in was filed where it is true: as the plain OSRM answer, which an ordinary
// caller now gets without asking again, and without the avoidance attached.
const plain = await calculateRouteWithLegs([a, b])
expect(osrm).toBe(1)
expect(plain.distance).toBe(4200)
expect(plain.avoidance).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
http.post(VALHALLA, () => HttpResponse.json({ trip: trip([[63, 10], [63.6, 11], [63, 12]], { has_highway: false, has_ferry: true }) })),
)
const routes = await calculateAlternatives({ lat: 63, lng: 10 }, { lat: 63, lng: 12 })
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'motorway', engine: 'valhalla', hasFerry: true })
})
})
+306 -54
View File
@@ -4,7 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
@@ -53,6 +53,31 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* Permission to turn around at a stop, for requests that have stops to turn around at.
*
* OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked otherwise,
* and a refusal is not local: the whole request comes back `400 NoRoute`, every leg of it,
* however many were fine. A stop on a dead end is enough. Gouffre de Padirac snaps onto
* `Route du Puits au Salvage`, which goes nowhere else, so a day that visits it in the
* middle lost all its legs at once while each of its pairs routed perfectly on its own.
* That is also why the fault looked like it depended on how the stops were spread across
* days: a pair on a day boundary is asked for on its own, and a request with nothing in
* the middle has nothing to refuse.
*
* Correct as well as convenient here. TREK's waypoints are places somebody stops at, not
* shape hints, and the way out of a dead end IS the way back in, and the constraint was
* buying a detour even where it did not refuse outright.
*
* Empty for two waypoints, which have no middle, so the many two-point callers
* (alternatives, the day connectors, booking geometry) keep the URL they had.
*/
const U_TURN_PARAM = '&continue_straight=false'
function uTurnParam(waypoints: readonly Waypoint[]): string {
return waypoints.length > 2 ? U_TURN_PARAM : ''
}
/**
* `fetch`, with the request counted.
*
@@ -79,10 +104,16 @@ async function routedFetch(
let km = 0
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
const asked = withoutUTurnIfRefused(url)
try {
const response = await fetch(url, { signal })
const response = await fetch(asked, { signal })
countRoute({ ...sample, failed: !response.ok })
return response
if (response.ok || !asked.includes(U_TURN_PARAM)) return response
const plain = await droppedUTurn(asked, response)
if (!plain) return response
const second = await fetch(plain, { signal })
countRoute({ ...sample, failed: !second.ok })
return second
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
countRoute({ ...sample, failed: true })
@@ -91,6 +122,49 @@ async function routedFetch(
}
}
/**
* Hosts that answered `InvalidQuery` to `continue_straight`, by origin.
*
* OSRM refuses an unknown query parameter outright rather than ignoring it, so a router
* that has never heard of this one would refuse EVERY request with a stop in the middle,
* which is a far worse fault than the one it fixes. `osrm-routed` has understood it
* since 5.6, so this is about something else answering under `routing_base_url`, and the
* first real request is the probe: there is no separate one.
*
* By origin rather than per profile, because a router either knows the parameter or does
* not; and in a Set rather than a setting, so pointing the instance at a router that does
* know it starts asking again without a reload. Same shape and the same reasoning as
* `excludeUnsupported` below.
*/
const uTurnUnsupported = new Set<string>()
const originOf = (url: string): string => {
try { return new URL(url).origin } catch { return url }
}
/** The url as it should go out, with the permission dropped for a host that refused it. */
function withoutUTurnIfRefused(url: string): string {
if (!url.includes(U_TURN_PARAM)) return url
return uTurnUnsupported.has(originOf(url)) ? url.replace(U_TURN_PARAM, '') : url
}
/**
* The same url without the u-turn permission, once it is clear the host objects to the
* PARAMETER and not to the route. Null when the refusal was about the route itself.
*
* OSRM says which it is: `InvalidQuery` names the query string, `NoRoute` names the road.
* Reading the body is safe here because this response is about to be thrown away either
* way, and it only happens on a 400 for a request that carried the parameter.
*/
async function droppedUTurn(url: string, response: Response): Promise<string | null> {
if (response.status !== 400) return null
let code: unknown
try { code = (await response.clone().json())?.code } catch { return null }
if (code !== 'InvalidQuery') return null
uTurnUnsupported.add(originOf(url))
return url.replace(U_TURN_PARAM, '')
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
@@ -142,6 +216,33 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) }
}
/**
* Which engine priced a route: OSRM, the second engine that weighs road classes away, or
* a route provider plugin. Their figures are three different speed models, so a figure
* from one read against a figure from another measures the models, not the roads.
*/
export type RouteEngine = 'osrm' | 'valhalla' | 'plugin'
/**
* The classes a request really weighs away, in a stable order, and none when it cannot.
*
* Only the driving profile has classes to leave out and only Valhalla can leave them out,
* so every other request is asked plainly whatever the trip says. One function for the
* day routing and for the offers beside it, so the two cannot disagree about which engine
* a leg belongs to.
*/
export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidClass[]): AvoidClass[] {
return profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
}
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
if (parsePluginProfile(profile)) return 'plugin'
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
}
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute(
waypoints: Waypoint[],
@@ -153,7 +254,7 @@ export async function calculateRoute(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false${uTurnParam(waypoints)}`
const response = await routedFetch(url, signal, 'route', profile, waypoints)
if (!response.ok) {
@@ -360,7 +461,7 @@ export async function calculateSegments(
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration${uTurnParam(waypoints)}`
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
@@ -406,9 +507,7 @@ export async function calculateRouteWithLegs(
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
const avoiding = avoidedClasses(profile, avoid)
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is
@@ -423,23 +522,30 @@ export async function calculateRouteWithLegs(
// way is worse: this Map is shared with the ordinary day planner, the studio and the
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
// under the plain key would hand them a road nobody drives and a second engine's
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
// also separates the two engines.
// times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot
// answer and OSRM drives the request after all, that answer is filed under the plain
// key, where it is exactly what an ordinary caller would have got. Filed under the
// avoiding key it stood in for the avoided road until the next reload, however soon
// Valhalla came back.
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const cacheKey = `${plainKey}${avoidScope}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
// day drawn rather than blank, and `avoidance` stays absent on that result so the
// rail can say the avoidance did not happen instead of implying it did.
if (routed) {
cacheRoute(cacheKey, routed)
return routed
}
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
// than blank, and the answer says that nothing was avoided, with the reason, so the
// rail flags the day instead of implying the setting held on a road nobody weighed.
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
}
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
@@ -484,11 +590,23 @@ export async function calculateRouteWithLegs(
return result
}
const result = await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(cacheKey, result)
return result
}
/** The OSRM answer for a chain, one leg per waypoint pair. Throws like every OSRM call here. */
async function osrmWithLegs(
waypoints: Waypoint[],
coords: string,
profile: RouteProfileKey,
signal?: AbortSignal,
): Promise<RouteWithLegs> {
// Written as literals rather than narrowing `profile`: its type is an open string union
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration${uTurnParam(waypoints)}`
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
@@ -518,9 +636,7 @@ export async function calculateRouteWithLegs(
)
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
cacheRoute(cacheKey, result)
return result
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
}
/** Store a route and drop the oldest once the cache is over its cap. */
@@ -585,6 +701,7 @@ async function routeAvoidingWithLegs(
legs,
snapped: run.snapped,
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
hasFerry: run.total.hasFerry,
}
}
@@ -640,7 +757,7 @@ function formatDuration(seconds: number): string {
return `${m} min`
}
/** One way of driving a leg, as OSRM offers it. */
/** One way of driving a leg, as a router offers it. */
export interface RouteAlternative {
coordinates: [number, number][]
distance: number
@@ -650,20 +767,24 @@ export interface RouteAlternative {
/** Set when this way exists because a road class was left out of it. */
avoids?: AvoidClass
/**
* Which engine priced this route, when it was not the one that priced the rest.
* Which engine priced this route. Absent means OSRM, which is what answers every route
* of a trip that avoids nothing, except the avoidance offer on a default install.
*
* Absent for everything OSRM answered, which is every route in a list except the
* avoidance offer on a default install. It exists because the two engines do not
* agree on speed: measured over twelve European legs the per-leg drive times run
* from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region:
* Valhalla is faster on Spanish autovía and slower through a city. So a figure from
* one of them subtracted from a figure from the other is not a difference in
* driving time, it is the gap between two speed models, and the reader has no way
* of telling the two apart.
* It exists because the engines do not agree on speed: measured over twelve European
* legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
* sign depends on the region: Valhalla is faster on Spanish autovía and slower through
* a city. So a figure from one of them subtracted from a figure from the other is not a
* difference in driving time, it is the gap between two speed models, and the reader
* has no way of telling the two apart.
*/
engine?: 'valhalla'
engine?: RouteEngine
/** Whether the way crosses by ferry, where the engine that drew it says so. */
hasFerry?: boolean
}
/** What two routes are compared by: their line, and their figures where there is none. */
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
/**
* Road classes worth asking the router to leave out, in the order they are tried.
*
@@ -706,10 +827,16 @@ const SAME_ROAD_KM = 0.5
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
const stray = furthestFrom(b.coordinates, a.coordinates)
if (stray) return stray.km < SAME_ROAD_KM
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates)
const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
@@ -788,12 +915,18 @@ async function valhallaExcluding(
// and every routing request an instance makes still lands in the one counter.
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
if (!leg || !legAvoids(leg, exclude)) return null
return valhallaOffer(leg)
}
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
return {
coordinates: leg.coordinates,
distance: leg.distance,
duration: leg.duration,
divergence: null,
engine: 'valhalla',
hasFerry: leg.hasFerry,
}
}
@@ -843,12 +976,89 @@ async function osrmExcluding(
* It is what makes a choice persistable: saving that point as a via forces the router back
* onto this road on every future request, without storing a polyline that would go stale
* with the next OSM update.
*
* `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by
* Valhalla, so its offers come from Valhalla too, weighed the same way, and never from
* OSRM: a list holding both engines is what offered a road the rail was not on as the
* quicker one, and clicking it changed nothing.
*/
export async function calculateAlternatives(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
{ signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
): Promise<RouteAlternative[]> {
const avoiding = avoidedClasses(profile, avoid)
const routes = avoiding.length
? await alternativesAvoiding(from, to, avoiding, limit, signal)
: await alternativesFromOsrm(from, to, profile, limit, signal)
if (!routes.length) return routes
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
/**
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
*
* Throws when Valhalla has nothing to say, so the picker reports a router that is not
* answering. Quietly offering OSRM's roads instead is the very mix this path exists to end.
*
* One way back gets the question the OSRM path asks too: the same drive with one more
* class left out, only of those the trip does not already avoid, and only where the road
* that comes back really is without it. Paced like the pieces of a split day, because the
* public instance allows one request a second.
*/
async function alternativesAvoiding(
from: Waypoint,
to: Waypoint,
avoiding: AvoidClass[],
limit: number,
signal?: AbortSignal,
): Promise<RouteAlternative[]> {
const sample = {
profile: 'driving' as const,
surface: 'alternatives' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: 2,
km: haversineKm(from, to),
}
const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !answered })
if (!answered?.length) throw new Error('No route found')
const routes: RouteAlternative[] = []
for (const leg of answered) {
const offer = valhallaOffer(leg)
if (!routes.some(r => sameRoad(r, offer))) routes.push(offer)
}
if (routes.length >= 2) return routes
for (const cls of EXCLUDABLE_CLASSES) {
if (avoiding.includes(cls)) continue
await valhallaSpacing(signal)
if (signal?.aborted) break
const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
if (!run || !legAvoids(run.total, cls)) continue
const detour = valhallaOffer(run.total)
if (routes.some(r => sameRoad(r, detour))) continue
routes.push({ ...detour, avoids: cls })
break
}
return routes
}
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
async function alternativesFromOsrm(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
limit: number,
signal?: AbortSignal,
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
@@ -886,11 +1096,6 @@ export async function calculateAlternatives(
}
}
}
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
@@ -908,31 +1113,78 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
function furthestFrom(
export function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { lat: number; lng: number; km: number } | null {
): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let best: { lat: number; lng: number } | null = null
let bestIndex = -1
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const [lat, lng] = line[i]
let nearest = Infinity
let nearestPoint: [number, number] | null = null
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
const near = nearestOn(line[i], reference, step)
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
}
if (!best) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { ...best, km }
if (bestIndex < 0) return null
const [lat, lng] = line[bestIndex]
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
@@ -87,4 +87,39 @@ describe('TripRouteOverviewPanel', () => {
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
expect(container).toBeEmptyDOMElement()
})
it('FE-MAP-TROU-009: says when legs could not be routed, and marks the day that is short', () => {
// A refused leg stays a straight line and adds nothing to the sum, so a total shown
// without a word about it reads as the whole trip when it is not.
const days = overview().days
const short = overview({
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 0 }],
unroutedLegs: 1,
})
render(<TripRouteOverviewPanel overview={short} unit="metric" />)
expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted')
expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1)
})
it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => {
// Every leg still waiting is unrouted too; the ellipsis already says the number grows.
const days = overview().days
const running = overview({
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 1 }],
unroutedLegs: 2,
loading: true,
})
render(<TripRouteOverviewPanel overview={running} unit="metric" />)
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
})
it('FE-MAP-TROU-011: a fully routed trip carries no warning at all', () => {
render(<TripRouteOverviewPanel overview={overview({ unroutedLegs: 0 })} unit="metric" />)
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
})
})
@@ -1,4 +1,4 @@
import { Route } from 'lucide-react'
import { AlertTriangle, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
@@ -66,6 +66,9 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
}) {
const { t } = useTranslation()
if (!overview.days.length) return null
// Only once the round is over: while it runs every leg still waiting is unrouted too,
// and the ellipsis on the total already says the number is growing.
const unrouted = overview.loading ? 0 : (overview.unroutedLegs ?? 0)
return (
<div
@@ -95,9 +98,23 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
</span>
</div>
{/* A leg the router refused stays a straight line and adds nothing to the sum, so
the total is short by however much road those legs are. Said in words rather than
left to the reader to notice, because the number is what a fuel estimate starts from. */}
{unrouted > 0 && (
<div
data-testid="trip-overview-unrouted"
className="text-warning"
style={{ display: 'flex', alignItems: 'flex-start', gap: 6, padding: '0 14px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}
>
<AlertTriangle size={12} style={{ flexShrink: 0, marginTop: 1 }} aria-hidden />
<span>{t('map.overview.unrouted', { count: unrouted })}</span>
</div>
)}
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
{overview.days.map(day => {
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const dayUnrouted = overview.loading ? 0 : (day.unroutedLegs ?? 0)
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
@@ -110,6 +127,17 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
{/* The day's own share of the shortfall, so the reader knows which figure
to distrust rather than only that one of them is off. */}
{dayUnrouted > 0 && (
<AlertTriangle
size={12}
className="text-warning"
style={{ flexShrink: 0 }}
role="img"
aria-label={t('map.overview.dayUnrouted', { count: dayUnrouted })}
/>
)}
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
</span>
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { COINCIDENT_RADIUS_PX, groupCoincidentPlaces, STACK_RADIUS_PX } from './coincidentPlaces'
interface Stop { id: number; x: number; y: number }
const stop = (id: number, x: number, y = 0): Stop => ({ id, x, y })
const project = (item: Stop) => ({ x: item.x, y: item.y })
describe('stops that share a spot on screen', () => {
it('COINCIDENT-001: stops on one coordinate become a single group', () => {
const groups = groupCoincidentPlaces([stop(1, 40), stop(2, 40), stop(3, 40)], project)
expect(groups).toHaveLength(1)
expect(groups[0].members.map(member => member.id)).toEqual([1, 2, 3])
expect(groups[0].lead.id).toBe(1)
})
it('COINCIDENT-002: the fold reaches no further than the pins that cover each other', () => {
const apart = groupCoincidentPlaces([stop(1, 0), stop(2, COINCIDENT_RADIUS_PX)], project)
expect(apart.map(group => group.members.length)).toEqual([1, 1])
// Inside the radius the lower pin has nothing showing to click at anyway.
const touching = groupCoincidentPlaces([stop(1, 0), stop(2, COINCIDENT_RADIUS_PX - 0.5)], project)
expect(touching.map(group => group.members.length)).toEqual([2])
// A pin's width apart is two ordinary neighbouring stops — a hotel and the
// restaurant across the street — and both of them keep a pin, since the renderer
// this grouping serves has no bubble to put the second one in (#2344).
const neighbours = groupCoincidentPlaces([stop(1, 0), stop(2, STACK_RADIUS_PX)], project)
expect(neighbours.map(group => group.members.length)).toEqual([1, 1])
})
it('COINCIDENT-003: the same stops group the same way whatever order they arrive in', () => {
const stops = [stop(3, 100), stop(1, 0), stop(2, 1), stop(1, 0)]
const groups = groupCoincidentPlaces(stops, project)
expect(groups.map(group => group.members.map(member => member.id))).toEqual([[1, 2], [3]])
expect(groupCoincidentPlaces([...stops].reverse(), project)).toEqual(groups)
})
it('COINCIDENT-004: a stop the map cannot place keeps a group of its own rather than vanishing', () => {
const missing = { id: 9, x: Number.NaN, y: 0 }
const groups = groupCoincidentPlaces([stop(1, 0), missing, stop(2, 400)], item => (
item.id === 9 ? null : project(item)
))
expect(groups.map(group => group.members.map(member => member.id))).toEqual([[1], [2], [9]])
expect(groupCoincidentPlaces([missing], project).map(group => group.lead)).toEqual([missing])
// And two of them stay apart: an item nothing could place cannot swallow another.
const unplaceable = groupCoincidentPlaces([stop(1, 0), stop(2, 0)], () => null)
expect(unplaceable.map(group => group.members.map(member => member.id))).toEqual([[1], [2]])
})
it('COINCIDENT-005: the selected stop is the one drawn for its stack', () => {
const stops = [stop(1, 40), stop(2, 40), stop(3, 400)]
expect(groupCoincidentPlaces(stops, project, 2)[0].lead.id).toBe(2)
// A selection from another stack, or none at all, leaves the first member leading.
expect(groupCoincidentPlaces(stops, project, 3)[0].lead.id).toBe(1)
expect(groupCoincidentPlaces(stops, project, null)[0].lead.id).toBe(1)
})
})

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