Compare commits

..

77 Commits

Author SHA1 Message Date
Maurice 14c767a675 fix(client): keep the open-at rule on the client's library target, and settle CI
Array.prototype.at is past the client's lib target, so the journey open-at
rule indexes the array. The packing row test expects the title that names
every bringer, a carousel test acts without a floating promise, and the
lint baseline drops to the counts this branch reaches.
2026-11-04 19:07:39 +01:00
Maurice da324121bd fix(packing): name everyone who brings a shared item
A common item brought by several members showed the first bringer's avatar
and a bare "+2". The tooltip only named the first person, and on the phone
neither the row nor the item sheet said who else brings it (discussion
#2782).

packingBringerNames lists the bringer and every contributor; the desktop
row, the phone row and the phone item sheet use it for "brought by".
2026-11-04 17:53:15 +01:00
Maurice b41f6e3542 fix(journey): a newest-first share link opens on the latest day on the phone
The public journey page on a phone shows the map with a card carousel, which
always opened on the first entry. The owner's "newest first" choice for the
link was ignored there, so readers following a running trip had to swipe
through every day to reach the latest one (discussion #2851).

The rule for where a journey opens moves out of the journal hook into
journeyOpenAtEntryId, which the public page now uses as well: a newest-first
link opens on the latest day, otherwise a running journey opens on today as
the journal already does.

The carousel also settles on the card at the end it is scrolled to. The last
card can never reach the centre, so opening on the latest day used to
activate the day before it, in the journal as well.
2026-11-04 17:53:15 +01:00
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
Maurice 82295a1d3f fix(planner): hold the tests and the road trip rule to the corrected morning stay
The road trip spec and wiki described the old choice of the stay a day wakes up in,
and three tests still looked for the Tour label.
2026-11-04 14:00:35 +01:00
Maurice 3c78452c14 fix(planner): start the day after a side trip at the side trip's hotel, and name the booked tour a guided tour
With a room kept for the whole trip and a second stay for a night away, the day
after the side trip took the first stay in the list as the hotel it woke up in.
The Days view then drew a drive from the kept room, often hundreds of kilometres
away, to the side trip's first stop or airport, next to the flight back. The
morning now belongs to the stay checked into last, and the evening to the stay
that runs on past the day. The map also passes a booking's own point at the edge
of the day, as the sidebar already did, so a taxi leaving from the hotel adds no
zero-length road from the hotel to itself.

The Tour booking type read like the Tours tab, and a booked food tour left people
looking at an empty Tours tab. It is now called Guided tour in every language,
the empty Tours tab says that booked tours go under Bookings, and the help
center and wiki use the new name. The stored type stays 'tour'.
2026-11-04 13:50:06 +01:00
Maurice 74864f2543 fix(places): keep the places repository at its size baseline
The insert's note now says that a longitude is stored on the real world, in
one line fewer.
2026-11-04 12:09:29 +01:00
Maurice 178df0fbe3 fix(map): keep the world-copy fix inside the CI ratchets
Moves the Leaflet map's event bridges (click, context menu, viewport, POI drop
and the longitude-frame follower) out of MapView into leafletMapBridges, and
shares one POI drop target between the Leaflet and GL maps, so both map files
shrink instead of growing. The repair migration does its arithmetic in one
UPDATE per table, and the new tests drop their non-null assertions and loops.
2026-11-04 11:37:52 +01:00
Maurice 51af575c9a fix(map): keep places and routes on one world, and start new installs with Journey and Collections
A right-click, click or drag on a repeated world copy reported the longitude it
has on that copy (274.9 for -85.1). The place was saved there, the geocoder
found nothing, and a route to a place on the real world ran across the whole
map (#2976). Longitudes are now brought back onto [-180, 180] where the map
hands them out (Leaflet and GL), where they are stored (places, booking
endpoints, collection places, so REST, MCP, plugin RPC and importers alike)
and before reverse geocoding. A migration moves the ones already stored.

Leaflet draws a layer at its literal longitude only, so a trip from Los
Angeles to Auckland had its pins at opposite ends of the world while the
flight ran off the edge to the next copy (#2975). A trip that crosses the
antimeridian is now drawn in one longitude frame, seamed in the widest
stretch it leaves empty, so pins, routes, flights and every overlay lie on
one copy and the map opens across the Pacific. Every other trip has no frame
and draws exactly as before. The GL day fit also takes the short way.

A new install now starts with Journey and Collections switched on, next to
the trip addons (all but Road trip), Atlas, Vacay and the Naver import that
already were. Existing installs keep their addons as they are.

Closes #2975
Closes #2976
2026-11-04 10:56:36 +01:00
Maurice 3e90ece087 fix(settings): list the phone plugin version date in the feature pairs 2026-11-04 00:28:49 +00:00
Maurice 65e5d4ab72 fix(settings): format the date format files prettier flagged 2026-11-03 23:39:06 +00:00
Maurice 154620b99a fix(settings): format two settings tests and keep the plugin panel at its size 2026-11-03 23:38:52 +00:00
Maurice adbe3a40f6 fix(settings): lint, size and test follow-ups for the date format
The admin reset link is a component of its own instead of one made during render, the version line of the plugin panels moved into small modules, the new settings rows hand their save on as void, the date picker takes an accessible name for fields without a visible label (the Vacay hire date), the Vacay and poll tests type through the shared picker, and the theme, size and lint baselines are lowered to what the files hold now.
2026-11-03 23:38:52 +00:00
Maurice 44c6fbf372 fix(settings): let every date follow a date format of the user's choice
Every date in TREK was written in the interface language's format, and
English is en-US, so an English interface always showed and read
mm/dd/yyyy with no way to change it. A few native date inputs followed
the browser instead, so one screen could mix two formats.

date_format is now a user setting with an admin default, next to the
time format: automatic (the language's format, as before), YYYY-MM-DD,
YYYY/MM/DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY. The date picker shows,
pre-fills, hints and reads typed dates in it, and every date written in
numbers across the app (lists, cards, admin panels, CSV and PDF exports)
goes through one formatter. Dates written with a month name keep the
language's wording. The remaining native date inputs are the shared
picker now. The display settings MCP tool reads and writes the key.

Closes #2971
2026-11-03 23:38:37 +00:00
Maurice 45690ecd14 chore(costs): keep the money helpers used only by settlementFx module-private 2026-11-03 23:21:12 +00:00
Maurice c6149cd64d fix(costs): move the settlement currency setup into the money helpers and hold the size baselines 2026-11-03 22:16:41 +00:00
Maurice 4587cf2099 Drop the unused within import and lower the test assertion baseline 2026-11-03 22:16:41 +00:00
Maurice 4585988dfd fix(costs): keep convertBooked at five parameters and update the tests to as-typed display rows
The display-currency check is its own helper (inDisplayCurrency), used by the Costs model and the shared trip page. The tests that pinned #2525's reading of a display-currency bill through the trip currency now pin the as-typed reading, which the balances use too; read in the trip currency, such a bill and its repayment may stay a euro cent apart.
2026-11-03 22:16:41 +00:00
Maurice a37a9c0fa8 fix(costs): count an expense in the display currency as typed
On a yen trip read in euros, an expense entered in euros was booked through the yen at the rate frozen on entry and shown back at today's rate, so 1,081.09 EUR read 1,078.86 EUR a day later, and repaying exactly what was typed left 2.23 EUR owed.

Rows in the display currency now net apart in whole display cents, as entered, and the lists show them as typed. A payment in the display currency first pays what is owed between the two in such rows; only the rest goes through the trip currency at its frozen rate, so a trip-currency debt settled in the display currency stays settled when rates move (#1445). Every other row still nets in trip cents and converts once (#1382), so the balances add up to zero in both halves.

Closes #2965
2026-11-03 22:16:41 +00:00
Maurice 0cde099c7f style(assistant): format the blind-call hint 2026-11-03 22:39:24 +01:00
Maurice e798eb712e fix(assistant): run a blind call, load its group, and say why when its arguments fail
Refusing every call to a tool of a group not loaded in the turn also stopped calls whose arguments were fine. The call now runs as before; its group is loaded at once, so the next step offers the schema, and when the arguments fail the MCP check the error tells the model the schema is there now and to send the call again.
2026-11-03 22:09:54 +01:00
Maurice 0dae908e05 fix(assistant): send back a call to a tool whose group the turn has not loaded
The model could call a tool it knew from an earlier turn without loading its group in this one. TREK ran it, but the model had never seen its schema in the turn, so numbers, arrays and objects came as strings and required arguments were missing, and the MCP check refused it with -32602 (mostly the Tours tools).

Such a call no longer runs: its group is loaded for the rest of the turn and the call is answered with a tool error asking for it again with the schema the next step now offers.

Closes #2967
2026-11-03 21:48:37 +01:00
Maurice 01c7bc9de2 fix(tours): plural toast, repo route and test updates for the waypoint import
The places toast is a plural group in every language, the points import goes through tourRepo instead of src/api, the hook and dialog keep their complexity, and the tests that checked the old confirm call or the GPX left-out count follow the new behaviour.
2026-11-03 20:42:14 +01:00
Maurice 2e683f8609 fix(tours): keep the waypoints of a GPX/KML file that also holds a track
With the Tours addon on, a file picked or dropped on the places column goes
into the Tours import as soon as it holds one line, and that import reads
lines only. A Sygic or Trip-o-matic export (a route plus its stops) came in
as one tour and every place was dropped without a word.

The preview now counts a GPX file's waypoints as it already counted a KML
file's pins, and the dialog offers to add those points as places of the
trip, on by default. Confirming runs the Tours import for the lines and the
places import (points only) for the same files. A file whose tracks the
trip already holds can still bring its points.

Closes #2969
2026-11-03 20:19:43 +01:00
Maurice f8e51cb83d fix(pdf): hand the trip PDF to Safari from the iOS Home Screen app
An iPhone or iPad running TREK from the Home Screen ignores print(), in
the page and in an iframe alike, so "Save as PDF" in the preview did
nothing there. Mobile Safari prints the same preview fine.

In the installed iOS app the preview now shows "Open in Safari" in
place of the dead button, with a line saying why. The link opens the
trip with ?export=pdf, and both shells open their export dialog or
sheet for it, so the PDF is one tap away once Safari is up. The
parameter is dropped once read and survives the login redirect.
Everywhere else the preview prints as before.

The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which
TripPDF had no room left for under lint:size, and its backdrop and
stacking now use the --overlay and --z-modal tokens.
2026-11-03 09:14:55 +01:00
Maurice eedfb08597 Managed mode: list the phone OIDC card and drop the map model from the default settings pair
- MAdminOidcCard is markup over the settings section's state, so it is
  listed under mobileOnly with that reason.
- The default user settings views take their presets from
  useDefaultUserSettings now and import only a type from mapSettingsModel,
  so that pair names the map settings views alone.
2026-11-03 08:44:45 +01:00
Maurice 0aee0a019e Managed mode: lower the test any baseline the journey helper brought down 2026-11-03 08:44:45 +01:00
Maurice 91bc1944dc Managed mode: keep the admin settings and addons ratchets green
- The phone's OIDC card moves into MAdminOidcCard, so the settings section
  is back within its complexity limit after the managed-mode conditions.
- enabledPhotoProviderIds reads the enabled ids straight from the
  repository and has a test of its own, which brings the addons coverage
  back to its threshold.
2026-11-03 08:44:45 +01:00
Maurice f99b40cd81 Managed mode: keep the journey page suite within its size baseline
- The owner sign-in and the photo provider the suite runs with move into
  tests/helpers/journeyDetail.ts, so the page test is back under its line
  baseline, which drops to 4028.
- Drop help/registry.base.ts, an unused copy of the help registry whose key
  builders the i18n key check could not resolve.
2026-11-03 08:44:45 +01:00
Maurice 59e68d81b2 Managed mode: satisfy the strict, loop and test ratchets
- enabledPhotoProviderIds skips rows without an id instead of widening the type.
- The listed events read each addon once, in parallel, not in a loop.
- The narrowing test reuses the suite's service and swaps the addon answers.
- PlaceContribPanel keeps its complexity by naming the standalone check.
2026-11-03 08:44:45 +01:00
Maurice 86513fda0d Managed mode: hand place search, routing, plugins and docs to the operator
On a managed install the operator runs place search and routing, the plugin
runtime, the integrations and the documentation, so the admin and the users
no longer see switches and guides for them:

- The place search and routing card, the transit provider and the search log
  are gone from the admin settings (desktop and mobile). places_provider is a
  managed-locked setting, and the transit provider, search log and wanderer
  routes refuse writes there. Share place details stays the admin's, as a card
  of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off.
- File types and trip reminders move to the right column, so the columns stay
  balanced.
- Plugins disappear from the admin and the user settings, wanderer from Tours.
- The Help & Docs page and its links are not offered. The help center stays and
  leaves out guides about what the operator runs (keys, OIDC, backups, storage,
  releases, plugins, document sync) and about integrations that are off.
- Mapbox GL is offered only with the operator's token, Amap tiles only where
  the operator set Amap up.
- The mobile admin no longer shows OIDC and the WebAuthn relying party fields,
  matching the desktop tab.

For every install:

- Notification matrices list only events the install can raise: no plugin row
  without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or
  packing rows while those are off. On a managed install the release and
  replica alerts are left out.
- The journey entry editor offers External photos only where a photo provider
  is switched on, as the phone already did.
- app-config is asked again after a failed answer. A page opened while the
  server restarted kept every default for the session, which on a managed
  install brought back the operator's settings.
2026-11-03 08:44:45 +01:00
Maurice 61378559ca chore(deps): runtime security floors and the CI action updates
- sharp 0.35.5 (librsvg CVE), with the musl pins that ship in the image.
- follow-redirects 1.16.2, postcss-selector-parser 6.1.5 and
  serialize-javascript 7.1.3, from the open advisories.
- GitHub Actions: github-script 9 and the minor-and-patch group.
- plugin-sdk: @types/update-notifier 6.0.9.

The grouped npm update is left out: with it the server build runs out of
memory in tsc, and the culprit is not found yet.
2026-11-02 21:58:16 +01:00
Maurice 84df69c18a Revert "chore(deps): take the safe updates from the open Dependabot PRs"
The server build runs out of memory in tsc with the grouped updates. Back to
the previous dependencies until the update that causes it is found.
2026-11-02 21:56:33 +01:00
Maurice 6194a658d9 chore(deps): take the safe updates from the open Dependabot PRs
- The npm minor-and-patch group (#2952) without mapbox-gl: 3.34 drops
  setRTLTextPlugin from its typings, and the RTL plugin is what joins Arabic,
  Hebrew and Persian labels. react and react-dom move to 19.3.1 together,
  overrides included, so the workspace keeps one React.
- Security floors from the open alerts: sharp 0.35.5, vite 8.3.5,
  follow-redirects 1.16.2 (#2927), postcss-selector-parser 6.1.5 (#2865) and
  serialize-javascript 7.1.3.
- GitHub Actions: github-script 9 (#2951) and the minor-and-patch group (#2950).
- plugin-sdk: @types/update-notifier 6.0.9 (#2948).
2026-11-02 21:30:17 +01:00
Maurice 97188c16d1 fix(place-contrib): mark the independent writes and use TREK's OSM id form in the catch-up test 2026-11-02 14:11:27 +01:00
Maurice 247d5fc519 feat(services): get the key at startup and share earlier place details
An installation now asks for its TREK services key a few minutes after
it starts, and the daily check registers a missing key as well as
renewing a due one. Until now nothing happened before someone searched
for a place or opened the admin settings, so "Share place details"
stayed unavailable on a fresh or updated instance.

When an admin agrees to share place details, what users typed into
places since TREK 5.1.0 goes out too, not only what they type from then
on. The origin of every field is recorded since 5.1.0 whatever the
decision, so a catch-up walks those places page by page before each
send run: only values that are still the ones a user typed, only for
fields switched on, only for places the service can key, and only
while the queue holds fewer than 200 values. It goes on where it
stopped after a restart, never replaces a newer value a save queued,
and ends on switching off, declining or withdrawing everything. A field
switched on later catches up as well, under an agreement given with the
new prompt sentence that says so; an agreement from 5.1.0 does not.

Queuing moves into PlaceContribQueue so a save and the catch-up take
the same path.
2026-11-02 13:56:32 +01:00
Maurice 8659262e59 fix(services): let slow handshakes reach the TREK services
Node gives each address of a dual-stack name 250 ms before it moves on.
From Australia that is shorter than one round trip to Europe, so on a
network without IPv6 the IPv4 handshake to places.liketrek.com was cut
off, the IPv6 attempt failed at once and every request ended in
ETIMEDOUT. The installation never got its services key, sharing place
details stayed unavailable and searches fell back to OpenStreetMap.

Raise the per-address attempt timeout to 2 s at startup, keeping a
larger value an operator already set, and log the reason behind
undici's bare "fetch failed" for each address tried.
2026-11-02 12:12:48 +01:00
Maurice 327ebc354f fix(push): accept Google's jmt17.google.com push endpoints
Chrome on Android increasingly hands out push subscriptions on
jmt17.google.com, which the push service allowlist did not know, so
turning push on failed with a 400. The host is added as an exact entry,
next to the two FCM hosts.

Closes #2939
2026-11-01 22:51:27 +01:00
roopesh 1625a10424 fix(server): refuse unwritable database in maintenance scripts (#2933) 2026-11-01 22:26:40 +01:00
Raz Erlich 94c2ef35b5 fix(planner): keep places selection bar visible with long lists 2026-11-01 22:08:45 +01:00
Maurice bf8e04741d test(storage): follow the release queue in the tests that pinned an immediate delete
Replacing a cover or an avatar and deleting a note file now hand the old
bytes to the release queue instead of deleting them in the request; the
integration tests check the queue row and that the bytes are still
there. Release mocks answer with a count, the reclaim e2e sends its
refused bodies one after another, and a stale-count reload that fails is
told to the admin instead of swallowed.
2026-11-01 18:46:58 +01:00
Maurice 0b598680ec test(storage): spell the pending-reset case title without a stray quote 2026-11-01 18:32:43 +01:00
Maurice 13332cf06b fix(storage): a pass on a database this installation does not recognise only reports
While a restore's reset is still pending, or the database names another
installation, the nightly pass judges and reports but deletes nothing,
whatever the restored switch says.
2026-11-01 18:32:33 +01:00
Maurice e6d52cb061 docs(wiki): say every restore resets the cleanup state and what the release queue gives up
A restore of this installation's own backup now resets it too, through the .trek-reclaim-reset file. Files dropped from the release queue after 30 days, or refused by a full queue, are only removed by the nightly cleanup once deletion is on.
2026-11-01 18:31:45 +01:00
Maurice bef8fc20f1 fix(storage): reset reclaim state after any restore, re-check owners often, share release ticks
- A restore (admin panel or RESTORE_FROM_BACKUP) now leaves
  data/.trek-reclaim-reset before it swaps the database in. Boot reconcile
  resets the reclaim state when it finds the file, whatever install id the
  database names, and removes it afterwards; a successful reopen removes it
  too. A restart after a failed reopen, or a boot restore of this
  installation's own backup, no longer keeps the old switch, marks and
  confirmations. The id mismatch reset stays.
- The release job skips a tick (install_mismatch) while the database names
  another installation or a reset is still pending.
- An allowed owner verdict is cached for 15 minutes, a refusal for 26 hours.
  The local driver reads a list prefix as a folder, so the category root is
  still listed whole, at most once per category per window.
- Each tick reads every allowed category's due rows separately, an equal
  share of the 200 each, so a category whose deletes keep failing cannot
  starve the others.
- Rows dropped after 30 days overdue and releases refused by a full queue
  are logged at warn with their counts.
2026-11-01 18:31:41 +01:00
Maurice 9d2ffc91af feat(storage): confirm the count shown, and say a backup or a missing install id held the cleanup
Confirm sends the count the admin saw and waits for its answer; a stale
count reloads the state. A running backup is named as a skip reason, a
missing installation id and a stale count as errors, and the switch's
hint no longer promises removal right away: deleted files go a day
later, once no undo can bring them back. Strings in all 28 languages.
2026-11-01 18:14:49 +01:00
Maurice 10ad64d170 docs(wiki): describe the day-later release, the install id file and the confirmation margin
Covers what a clone must do (its own data dir, or delete .trek-install-id),
that an older TREK writes no owner marker and cannot be detected, and the
new backup_running reason.
2026-11-01 18:11:47 +01:00
Maurice 1c6a175ebc fix(storage): queue what a delete left unnamed past every undo, and tell a copied database apart
A delete or a replace no longer deletes inside the request. The owner's call
queues the keys in storage_release_queue, due after RELEASE_DELAY_MS (26 h:
the assistant's 24 h approval window plus a margin), and StorageReleaseJob
takes the due rows every ten minutes, at most 200 a tick and 50 keys a
question, asks every reference source again and deletes what none names.
It refuses what the nightly pass refuses (StorageReclaimGuardService: the
ledger, the required sources, a shared location, another installation's
owner marker, cached per category), and waits while a restore or a backup
runs or another storage job holds the slot. An assistant undo or a late
write that names the old value again keeps its object.

The install id lives in data/.trek-install-id, outside any backup and never
written by a restore. At boot and after every reopen the database is
reconciled with it: a database naming another installation (a boot restore,
a restart after a failed restore, a copied travel.db) loses its switch,
confirmations, reports, marks and queue before anything reads them.

A confirmation now stores the count the admin was shown and when; it covers
up to one and a half times that count (at least 100 more) for 14 days, is
used up once the share is back under a fifth, and a count the last report
no longer holds is refused as reclaim_confirm_stale. The admin state folds
a live confirmation in, so the prompt goes at once.

A pass reads each source once for every category and drops each category's
keys once judged; right before a batch it reads the category's sources
again, whole and in memory, instead of a LIKE per key. A narrowed read holds
at most 50 LIKE terms. A pass held off by a backup reports backup_running,
and a category the ledger never reclaims reports not_reclaimed even after a
stop.
2026-11-01 18:11:43 +01:00
Maurice 0a25de2c6d feat(storage): confirm the reclaim count the admin was shown, name a running backup as a skip, bound a trip cover
The confirm request now carries the unreferenced count the admin saw, so the
server can refuse a count its last report no longer holds. A pass that found
objects due while a backup read the uploads reports backup_running for them.
A trip cover is bounded to 2048 characters on REST, RPC and the MCP tool, as a
place image already is, since a replaced cover is queued for release by every
upload path it spells out.
2026-11-01 18:11:25 +01:00
Maurice 59a6d92cea fix(storage): type the key search run, split the reclaim view per category
The longest-run helper typed its reduce as never, reclaimView grew past
the complexity limit (one function per category line now), and a test
read a promise as a boolean.
2026-11-01 16:37:00 +01:00
Maurice e0982e708f feat(storage): show running checks, confirmations and the nightly result in Admin > Storage
The panels follow the reworked reclaim state: Check now starts a check
in the background and the panel polls until it is done, a category with
an unusually large share of unused files offers Confirm, Freed comes
from the last run that deleted, a stopped check says why and no longer
reads as nothing found, the switch waits for its answer and a poll that
was in flight cannot flip it back, and refusals name their reason in
the operator's language. Strings updated in all 28 languages.
2026-11-01 16:25:38 +01:00
Maurice d17457de56 fix(storage): delete what a delete or a replace left unnamed right away again
Leaving every replaced cover, place image and deleted journey photo to the
nightly pass kept them publicly fetchable by URL for a week at least, and
for good while deletion was off. Owners now hand the values a delete or a
replace left behind to StorageReleaseService once their write committed.
It asks every registered reference source, with the same parser as the
nightly pass, and deletes at once what no row names any more: an object a
trip copy, a journey or a saved place still shows stays, which the old
inline deletes got wrong.

The release holds the pass's guards (reclaimable categories at their root
only, never an owner marker, never while a restore runs, never with a
required source missing or an overlapping location), never fails the
request and logs at debug. It runs for trip covers (update, upload, trip
delete), collection covers and saved-place images, place images (update,
bulk update, delete), journey covers and journey or gallery photos
(their thumbnail goes with a file that went), avatars (change, delete,
account deletion), trip files (permanent delete, empty trash) and collab
note and chat attachments. A trip file row now goes before its bytes, so a
failing backend no longer fails the delete. The objects of a deleted trip's
places and files are left to the nightly pass: their rows are other
domains'.

PlacesService drops the collection places repository nothing read any
more. Size, warning and boundary baselines follow the shrunk files.
2026-11-01 16:18:21 +01:00
Maurice 790fccb92c fix(storage): judge unreferenced files without a brake anyone can trip
The 20 % brake skipped a category and wiped its marks every night once a
fifth of it looked unreferenced. Any user could push a small install over
that line, a backlog from #2924 started braked, and a second install on the
same bucket below the line was not caught at all. It is gone:

- Each pass leaves a trek-owner-<install id> marker at the root of every
  category it judges. A category holding another install's marker is
  skipped as foreign_install, one where the marker cannot be written as
  owner_unknown. Backups leave the markers out.
- The install id lives in app_settings outside the storage.* prefix. The
  switch is stored as that id, so a database from another install reads as
  off, and every reopen (a restore) writes this install's id back, turns
  deletion off and drops the marks, confirmations and reports.
- More than a fifth unreferenced still marks and reports, but deletes only
  after an admin confirmed the count shown (POST .../reclaim/confirm). A
  larger count later waits again.

A skip no longer touches the marks, so a failed listing or lookup does not
restart the week. Check now runs in the background and answers 202; the
admin state says running and keeps the last deleting report apart, so a
report-only check never resets what was freed. A pass that deletes writes a
system audit entry, a pass that stops or fails keeps its partial counts and
marks the rest interrupted, a backend error stops deleting in its category,
and deleting stops for the rest of the pass once the switch is off or a
backup is reading the uploads. The usage scan waits for a pass still
running.

Sources read each category once per pass and answer the pre-delete
re-check with a query narrowed to the keys' plain runs. Bare names keep
their raw spelling (a '#', '?' or trailing blank is part of an upload's
name), and local roots are resolved through their symlinks before the
overlap check. A pass lists only a category's root.
2026-11-01 16:18:08 +01:00
Maurice e59992d56d chore(ci): lower the knip baseline the reclaim branch shrank
storage-registry.service.ts no longer exports a type nothing uses.
2026-11-01 14:55:54 +01:00
Maurice 382797febc fix(storage): let the app boot with the reclaim routes and keep the ratchets where they were
The two admin routes go on the managed allow list, without which buildApp
refused to start. The reclaim calls move to their own api module so
client.ts stays at its size, the phone card sizes its text through the
type tiers, and the tests drop a hand-built service, a non-null assertion
and a promise read as a boolean.
2026-11-01 14:44:53 +01:00
Maurice a1d9cdc8f2 feat(storage): show unreferenced files in Admin > Storage, with the switch and a check
Desktop and phone show what the last check found (can be freed, unused for
less than a week, freed), why a category was skipped, the switch that lets
the nightly run delete and a report-only check on demand, through the
shared storage panel hook. All 28 languages, and the Admin-Storage wiki
page explains the check, the week's wait and when to leave it off.
2026-11-01 14:30:01 +01:00
Maurice b31666d1c9 fix(storage): re-check a batch right before its delete, and drop reads nothing calls
The last question to the sources now comes after the object's own check,
so nothing slower than the delete sits between the answer and the delete,
and it is asked once per batch instead of once per object: a pass read
every source's columns for each object it was about to remove. The
collection reads the removed inline reclaims used are gone with them.
2026-11-01 14:23:04 +01:00
Maurice 19e4f0dadf fix(storage): keep the reclaim pass and a backup restore apart
A restore wipes and rehydrates the uploads under a swapped database, but it
never took the storage job slot, so a reclaim pass could start or carry on
beside it, and a delete already past the generation check could land on an
object the restore had just put back. BackupService now marks a restore as
running on StorageJobsService and waits for a pass in flight to let go; a
pass refuses to start while a restore runs and stops at its next check when
one begins. Backfills and migrations are not affected. The service test also
reads its fake source sets through a helper instead of non-null assertions.
2026-11-01 14:20:11 +01:00
Maurice 8fde643347 fix(storage): make the reclaim state on the admin storage state nullable
The shared contract made reclaim a required field, so every client fixture
typed StorageAdminState stopped compiling. It is nullable now, as planned,
and the five base fixtures carry reclaim: null.
2026-11-01 14:20:11 +01:00
Maurice f44dd92ee0 test(storage): run the reclaim e2e and source check on the whole app
The reclaim e2e and the source-registration check mocked src/db/database,
which lint:test-mocks holds at zero for new files; both now boot buildApp
against the worker's snapshot db. The key parser drops its non-null
assertions (the indexed reads are typed string already), and the e2e sends
its refused bodies at once instead of awaiting inside a loop, so the eslint
ratchet stays where it was.
2026-11-01 14:20:10 +01:00
Maurice db0fbc1ccd fix(storage): stop deleting covers, place images and journey photos another row may still show
Replacing a trip or list cover, changing or deleting a place or saved-place
image and deleting a journey photo removed the bytes at once, without asking
whether a trip copy, a journey, a saved place or another photo row still
named them (#2924). Those paths now leave the object in place and the reclaim
job removes it once no row names it. A journey photo the user deletes takes
its local photo row with it when nothing else links it, so its bytes become
reclaimable instead of being kept by a row nobody sees.

A place image copied from a trip file is still dropped at once when the
update that would name it fails: that copy was never anyone else's.

Size and warning baselines follow the shrunk files.
2026-11-01 13:59:28 +01:00
Maurice 095bed2db8 feat(storage): let every owner of an upload column tell the reclaim pass what it still names
Trips, collections, places, the contribution queue, journeys, photos, trip
files and users each register a reference source from their own domain. A
source reads its own columns and parses every spelling they hold through the
storage helpers, and the free-form columns claim every reclaimable category,
since a cover or an image can point into any of them. The contribution queue
joins the ledger as its own source, because place-contrib owns that table.
2026-11-01 13:59:19 +01:00
Maurice fb2429ebb2 feat(storage): find and reclaim upload objects no row names any more
Domains whose rows name stored objects register a reference source with
StorageReclaimRegistryService; storage never imports them. A ledger in
storage decides per category whether it is reclaimed at all and which
sources must have answered first, and a category whose resolved location
overlaps another one is left alone.

A nightly pass at 04:05 lists the root level of files, journey, covers,
avatars and places, skips objects younger than a day or without a
modification time, asks every source in pages of 500 and marks the rest.
Only with deletion switched on, and only for marks a week old, it asks every
source about the single key again and deletes through StorageService. A
category is skipped and its marks start over when a source is missing or
fails, the listing fails, or more than a fifth of its objects look
unreferenced. A pass shares the one-job slot with backfills and migrations,
never runs on a demo instance and stops when the database is reopened.

Admins switch deletion and run a pass on demand under
/api/admin/storage/reclaim; both are audited, and GET /api/admin/storage
carries the switch and the last report. Deletion is off after install and
upgrade.
2026-11-01 13:37:53 +01:00
Maurice 29298dcfe5 feat(storage): keep a mark per stored object no row names
storage_reclaim_marks records since when the reclaim pass has seen an object
unreferenced, keyed by category and key. A mark is never moved forward, so
its age is how long the object has been unreferenced without a break; it goes
once the object is named again or gone. Owned by the storage domain.
2026-11-01 13:37:44 +01:00
Maurice 630f15b5ea feat(storage): describe the reclaim report, its switch and the run request in the shared contract
The admin state now carries the reclaim switch and the last pass's report.
A report covers every served category, the legacy photos folder included,
with the objects a pass looked at, the unreferenced, eligible and deleted
counts and bytes, and the reason a category was skipped.
2026-11-01 13:37:37 +01:00
Maurice 04debce79e fix(planner): keep the lint and size ratchets where they were
The zone helpers of the day order take the merged row's own type instead
of an explicit any, and the day list's size baseline follows it down.
2026-11-01 11:41:56 +01:00
Maurice 4003769a5c fix(planner): keep the date whole and rename the title where it stands with the date first
With "date first in day headings" a long day title squeezed the date in
the day list to "Th…" (#2920); the name now gives way and the date stays
whole. The day panel and the mobile day sheet kept the rename pencil on
the heading, which then is the date, and the input replaced it (#2921);
pencil and input now go with the title line under the date.
2026-11-01 11:41:56 +01:00
Maurice 7faf771f9c fix(docker): look at the volume roots only, and say when chown is refused
The ownership check still read every inode of the volumes on each start.
It now looks at the dirs and what sits directly in them, hands a volume
over once when it is not node's, and logs a warning instead of walking a
volume that refuses chown (NFS with root_squash).
2026-11-01 11:41:56 +01:00
Maurice 51b991f851 fix(docker): the container starts on large volumes and under runAsUser 1000
The entrypoint no longer runs chown -R over data and uploads on every start;
it only hands over files that are not node's yet, and logs when it does.
Started as a non-root user, it skips chown and gosu and runs Node directly.
The chart exposes podSecurityContext and securityContext for that setup.
2026-11-01 11:41:56 +01:00
Maurice 8692b8790c fix(planner): judge a move by the order a day is drawn in across time zones
The checks behind a drag and the up/down buttons read each booking's raw
clock, so on a day whose flights were put in the order they happen every
move of a timed stop asked to change its time (desktop) or was refused
(mobile). They now read the same key the day is ordered by.
2026-11-01 11:41:56 +01:00
Maurice 1b6a156969 fix(planner): order flights across time zones by when they actually leave
A booking's time is local to its own airport, so a flight leaving
Auckland at 20:20 and landing in Los Angeles at 12:25 the same date
was drawn below the 17:00 flight on from Los Angeles. Bookings whose
dated time has a known zone now take their clock slots in real order.
2026-11-01 11:41:55 +01:00
Maurice f076cb82da docs(readme): link dataforest cloud wherever the sponsor section names it 2026-11-01 10:20:21 +01:00
Maurice 073db83421 fix(assistant): answer the calls of a stopped step and ask for the words behind wordless follow-ups
A step stopped while its calls ran (the user left the page) kept the
provider's whole record of it but answers only for the calls that ran, so
Anthropic and the Responses API refused that thread with HTTP 400 from then
on. Replaying the thread now answers every recorded call that never ran,
which also mends threads already stuck that way.

Claude Sonnet 5.5 at times ends a turn on suggest_follow_ups alone, without
writing its answer, and the turn failed as empty_answer. That step now goes
back to the model whole, its follow-ups answered that the user saw nothing
yet, and their chips come under the answer it then writes. The prompts no
longer say the call goes alone in its step.
2026-11-01 10:18:27 +01:00
Maurice cafc8dd46e fix: link dataforest cloud with /en again
The 5.1.0 popup, the sponsor panel in Settings > About and the README had
switched to https://cloud.dataforest.net/?mtm_campaign=trek. The link stays
https://cloud.dataforest.net/en?mtm_campaign=trek everywhere.
2026-10-31 12:26:36 +01:00
656 changed files with 17319 additions and 2307 deletions
@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const badTitles = [
@@ -15,7 +15,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Flag or redirect issue
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const title = context.payload.issue.title.trim();
+2 -2
View File
@@ -175,7 +175,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+2 -2
View File
@@ -342,7 +342,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -401,7 +401,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const base = context.payload.pull_request.base.ref;
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.createComment({
+2 -2
View File
@@ -74,12 +74,12 @@ jobs:
load: true
tags: trek:scan
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
- uses: docker/scout-action@e7e2a72bf1485d10a58f30696b6c513b261ce4fa # v1.29.0
with:
command: cves
image: trek:scan
+9 -1
View File
@@ -1100,7 +1100,7 @@ jobs:
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
@@ -1274,6 +1274,14 @@ jobs:
--set httpRoute.enabled=true \
--set 'httpRoute.parentRefs[0].name=gateway' \
> "$out/http-route.yaml"
helm template trek charts/trek \
--set podSecurityContext.runAsUser=1000 \
--set podSecurityContext.runAsGroup=1000 \
--set podSecurityContext.runAsNonRoot=true \
--set podSecurityContext.seccompProfile.type=RuntimeDefault \
--set securityContext.allowPrivilegeEscalation=false \
--set 'securityContext.capabilities.drop[0]=ALL' \
> "$out/non-root.yaml"
helm template trek charts/trek \
--set ingress.enabled=true \
--set fullnameOverride=custom-trek \
+9 -9
View File
@@ -36,7 +36,7 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge
&nbsp;
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
</div>
---
@@ -218,13 +218,13 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<br />
## Powered by dataforest cloud
## Powered by [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek)
<div align="center">
<br />
<a href="https://cloud.dataforest.net/?mtm_campaign=trek">
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
@@ -235,17 +235,17 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<br />
<br />
**Every TREK installation in the world searches through servers that dataforest cloud sponsors.**
**Every TREK installation in the world searches through servers that [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) sponsors.**
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on dataforest cloud in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
Thank you, dataforest, for backing open source this generously. 💚
Thank you, [dataforest](https://cloud.dataforest.net/en?mtm_campaign=trek), for backing open source this generously. 💚
<br />
<a href="https://cloud.dataforest.net/?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
Looking for a server for your own TREK? [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) is where TREK's own services live.
</div>
@@ -277,7 +277,7 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
> [!TIP]
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
<div align="center">
+1
View File
@@ -56,4 +56,5 @@ See `values.yaml` for more options.
- `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.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
+7 -1
View File
@@ -29,12 +29,18 @@ spec:
- name: {{ .name }}
{{- end }}
{{- end }}
{{- with .Values.podSecurityContext }}
securityContext:
fsGroup: 1000
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
+28
View File
@@ -10,6 +10,34 @@ image:
# processes on one host, which network filesystems do not provide.
updateStrategy: Recreate
# Pod-level security context. fsGroup gives the node user (uid/gid 1000) group access
# to the data and uploads volumes; OnRootMismatch lets the kubelet skip its recursive
# permission walk once the volume root already matches, so a big PVC does not hold up
# every start.
#
# By default the container starts as root, hands any files that are not node's yet to
# node, and then drops to node itself. To start it as node straight away (needed under
# the "restricted" Pod Security Standard), add the user here and lock the container
# down below; the image then skips the ownership step and the privilege drop:
# podSecurityContext:
# fsGroup: 1000
# fsGroupChangePolicy: OnRootMismatch
# runAsUser: 1000
# runAsGroup: 1000
# runAsNonRoot: true
# seccompProfile:
# type: RuntimeDefault
# securityContext:
# allowPrivilegeEscalation: false
# capabilities:
# drop: [ALL]
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# Security context of the trek container itself. Empty by default.
securityContext: {}
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
+110
View File
@@ -0,0 +1,110 @@
import { expect, test, type Locator } from '@playwright/test';
// A visible DOM node can still be clipped by a panel. Check the whole bar
// before interacting: Playwright's automatic scroll-into-view could mask it.
async function expectBarInsidePanel(bar: Locator, panel: Locator) {
await expect(bar).toBeInViewport({ ratio: 1 });
await expect
.poll(async () => {
const bounds = await bar.boundingBox();
const parent = await panel.boundingBox();
return (
!!bounds &&
!!parent &&
bounds.y >= parent.y &&
bounds.y + bounds.height <= parent.y + parent.height + 1 &&
bounds.x >= parent.x &&
bounds.x + bounds.width <= parent.x + parent.width + 1
);
})
.toBe(true);
}
for (const layout of [
{ name: 'desktop', viewport: { width: 1440, height: 900 }, hasTouch: false },
{ name: 'tablet', viewport: { width: 834, height: 1112 }, hasTouch: true },
{ name: 'short desktop', viewport: { width: 1280, height: 600 }, hasTouch: false },
]) {
test.describe(layout.name, () => {
test.use({
viewport: layout.viewport,
hasTouch: layout.hasTouch,
});
for (const tours of [false, true]) {
for (const count of [10, 27, 190]) {
test(`${count} places, Tours ${tours ? 'on' : 'off'}: selection bar stays reachable`, async ({ page }) => {
test.setTimeout(90_000);
const activate = (button: Locator) => (layout.hasTouch ? button.tap() : button.click());
const addonsResponse = await page.request.get('/api/admin/addons');
expect(addonsResponse.ok()).toBeTruthy();
const { addons } = await addonsResponse.json();
const originalTours = addons.find((addon: { id: string }) => addon.id === 'tours');
expect(originalTours).toBeDefined();
let tripId: number | undefined;
try {
expect((await page.request.put('/api/admin/addons/tours', { data: { enabled: tours } })).ok()).toBeTruthy();
const tripResponse = await page.request.post('/api/trips', {
data: {
title: `Selection bar ${layout.name} ${count}`,
start_date: '2027-01-01',
end_date: '2027-01-02',
},
});
expect(tripResponse.ok()).toBeTruthy();
tripId = (await tripResponse.json()).trip.id;
for (let i = 1; i <= count; i++) {
const response = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name: `Selection place ${i}`, lat: 48.85, lng: 2.35 },
});
expect(response.ok(), `seed place ${i}`).toBeTruthy();
}
await page.goto(`/trips/${tripId}`);
if (layout.name === 'tablet') await page.locator('button[aria-label="Places"]').click();
const sidebar = page
.locator('[data-touch-drag]')
.filter({ has: page.getByRole('button', { name: 'Select', exact: true }) });
const panel = sidebar.locator('..');
const rows = sidebar.getByRole('option');
await expect(rows).toHaveCount(count);
await expect(panel.getByRole('tab', { name: 'Tours', exact: true })).toHaveCount(tours ? 1 : 0);
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
const bar = sidebar.getByRole('status').locator('../..');
await expectBarInsidePanel(bar, panel);
const list = sidebar.locator('.trek-stagger');
const before = await bar.boundingBox();
// Only the list scrolls; the bar must remain at the panel's foot.
await list.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
if (count >= 27) await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
await expectBarInsidePanel(bar, panel);
expect((await bar.boundingBox())?.y).toBe(before?.y);
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
await expect(sidebar.getByRole('status')).toHaveText(String(count));
await activate(sidebar.getByRole('button', { name: 'Done', exact: true }));
await expect(sidebar.getByRole('status')).toHaveCount(0);
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
await list.evaluate((el) => {
el.scrollTop = 0;
});
await expectBarInsidePanel(bar, panel);
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
await activate(sidebar.getByRole('button', { name: 'Delete selected', exact: true }));
await activate(page.getByRole('button', { name: 'Delete', exact: true }));
await expect(rows).toHaveCount(0);
} finally {
if (tripId !== undefined) expect((await page.request.delete(`/api/trips/${tripId}`)).ok()).toBeTruthy();
expect(
(await page.request.put('/api/admin/addons/tours', { data: { enabled: !!originalTours.enabled } })).ok()
).toBeTruthy();
}
});
}
}
});
}
+1 -1
View File
@@ -138,7 +138,7 @@
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 380,
"@typescript-eslint/no-non-null-assertion": 377,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
@@ -8,14 +8,14 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+5 -5
View File
@@ -742,11 +742,8 @@
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Map: useMapSettingsForm",
@@ -983,8 +980,11 @@
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
+1 -1
View File
@@ -34,7 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/PDF/TripPDF.tsx": 8,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
+6 -7
View File
@@ -2,23 +2,22 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
+2 -2
View File
@@ -71,7 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/PDF/TripPDF.tsx": 53,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
+22
View File
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
// A page opened while the server restarts got a 502 here and kept every
// default for the session, which brought back the operator's settings.
let calls = 0
server.use(
http.get('/api/auth/app-config', () => {
calls += 1
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
})
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
expect(calls).toBe(2)
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
+25 -3
View File
@@ -323,7 +323,21 @@ 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; 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; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
// Asked again until it answers: a page opened while the server restarts got a
// 502 here and kept the defaults for the whole session, which on a managed
// install put back every setting the operator owns.
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
if (cancelled) throw err
return new Promise<AppConfigAnswer>((resolve, reject) => {
retryTimer = setTimeout(() => {
fetchAppConfig(attempt + 1).then(resolve, reject)
}, Math.min(30_000, 1_000 * 2 ** attempt))
})
})
fetchAppConfig(0).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; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -347,6 +361,10 @@ export default function App() {
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
return () => {
cancelled = true
clearTimeout(retryTimer)
}
}, [])
const { settings } = useSettingsStore()
@@ -402,6 +420,10 @@ export default function App() {
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
// The Help & Docs page is the wiki, written for somebody who runs TREK
// themselves: keys, backups, updates, the server behind it. A managed install
// does not offer it; the in-app help center stays.
const managed = useAuthStore(s => s.managed)
return (
<TranslationProvider>
@@ -456,7 +478,7 @@ export default function App() {
path="/help"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -464,7 +486,7 @@ export default function App() {
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
+15
View File
@@ -0,0 +1,15 @@
import type { StorageReclaimConfirmRequest, StorageReclaimState } from '@trek/shared';
import { apiClient } from './client';
/** Admin > Storage, unreferenced files: the nightly deletion switch, a check on demand, and the large-share confirmation. */
export const storageReclaimApi = {
setDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
/** Starts a report-only check in the background (202); its report arrives through the admin state. */
check: (): Promise<{ started: true }> => apiClient.post('/admin/storage/reclaim/run').then((r) => r.data),
/** `unreferenced` is the count the admin was shown: the server refuses when its own count has moved on. */
confirm: (request: StorageReclaimConfirmRequest): Promise<StorageReclaimState> =>
apiClient
.post('/admin/storage/reclaim/confirm', request satisfies StorageReclaimConfirmRequest)
.then((r) => r.data),
};
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
seedStore(useAuthStore, { managed: true });
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
render(<AddonManager />);
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
await unfoldShelves();
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
+13 -8
View File
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...(wandererOffered
? [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
]
: []),
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
const date = (iso: string) => formatDate(new Date(iso))
return (
<div>
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
import { deriveCaps } from './pluginCaps'
import PluginPoiCategoryList from './PluginPoiCategoryList'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { detail, failed } = usePluginDetail(item.id)
const { formatDate } = useFormatDate()
const manifest = detail?.manifest ?? null
const permissions = manifest?.permissions ?? []
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}>
<VersionLine v={v} locale={locale} />
<VersionLine v={v} />
</DialogListItem>
))}
</DialogList>
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<DialogShell
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
{current ? (
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
) : v.compatible ? (
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
});
});
describe('DefaultUserSettingsTab date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await pickFromSelect(user, '2026-12-31', '31.12.2026');
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
});
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
});
});
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import type { MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onChange={saveMapProvider}
options={[
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]}
/>
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -1,4 +1,4 @@
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
import userEvent from '@testing-library/user-event';
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
import { http, HttpResponse } from 'msw';
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
vi.useRealTimers();
}
});
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
const asked = vi.fn();
server.use(
http.get('/api/admin/places-contribute', () => {
asked();
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
})
);
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
await waitFor(() => expect(asked).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
render(<PlaceContribPanel hideWhenSwitchedOff />);
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
});
});
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
* way to take everything back. Turning it on for the first time opens the same
* prompt an admin gets on the first visit, so an agreement always comes from it.
*/
export default function PlaceContribPanel() {
/**
* Standing alone (managed settings), a card that can only say "not available"
* because the operator switched sharing off is a dead end.
*/
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
return alone && state.ineligibleReason === 'env_off';
}
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
const { t } = useTranslation();
const toast = useToast();
const contrib = usePlaceContrib();
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
if (!state) return null;
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
const status = contribStatus(state);
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
// The prompt opens by itself once per visit while the server asks for it.
@@ -175,7 +175,7 @@ export default function PlaceContribPrompt({
))}
</ul>
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
{t('admin.contribute.prompt.how')}
{t('admin.contribute.prompt.how')} {t('admin.contribute.prompt.past')}
</p>
</section>
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
@@ -36,6 +37,9 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
// wanderer sends a user's searches to a server of their choosing, which a
// managed install's terms do not cover; the operator decides, not the admin.
const wandererOffered = !useAuthStore((s) => s.managed);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
@@ -160,6 +164,7 @@ export function useAddonManager() {
providerOptions,
documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
};
@@ -0,0 +1,25 @@
import { ShieldCheck } from 'lucide-react';
import { useFormatDate } from '../../../hooks/useDateFormat';
import { fs } from '../../shared/DialogShell';
import type { VersionInfo } from './pluginModel';
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate();
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
style={fs(12.5)}
>
v{v.version}
</span>
{v.publishedAt && (
<span className="text-content-faint" style={fs(11.5)}>
{formatDate(new Date(v.publishedAt))}
</span>
)}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
);
}
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageCategory, type StorageConfig } from '@trek/shared';
import { MASKED_SETTING_VALUE, STORAGE_RECLAIM_CATEGORIES, type StorageAdminState, type StorageCategory, type StorageConfig, type StorageReclaimReport } from '@trek/shared';
import { server } from '../../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { ToastContainer } from '../../shared/Toast';
@@ -11,6 +11,16 @@ const S3_MASKED = {
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
};
/** A last check with three covers a week old and two younger ones; every other category never reclaimed. */
function reclaimReport(): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: 'not_reclaimed' as const }]),
) as StorageReclaimReport['categories'];
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: null };
categories.places = { objects: 10, unreferenced: { objects: 3, bytes: 300 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: true, skipped: null };
return { startedAt: Date.now() - 60_000, finishedAt: Date.now() - 30_000, mode: 'report', stopped: null, categories };
}
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
return {
backends: [
@@ -35,6 +45,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
},
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -1217,4 +1228,55 @@ describe('AdminStoragePanel', () => {
expect(within(row).getByText(/Sync failed: disk full/)).toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Sync now' })).toBeInTheDocument();
});
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch, the check and a confirmation go to the server', async () => {
const lastRun = reclaimReport();
let put: unknown = null;
let runs = 0;
let confirm: unknown = null;
server.use(
http.put('/api/admin/storage/reclaim', async ({ request }) => {
put = await request.json();
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
http.post('/api/admin/storage/reclaim/run', () => {
runs += 1;
return HttpResponse.json({ started: true }, { status: 202 });
}),
http.post('/api/admin/storage/reclaim/confirm', async ({ request }) => {
confirm = await request.json();
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun, lastDeleteRun: null } }));
// Once a check started, the state the panel polls says it runs.
server.use(
http.get('/api/admin/storage', () =>
HttpResponse.json(baseState({ reclaim: { deleteEnabled: true, running: runs > 0, lastRun, lastDeleteRun: null } })),
),
);
expect(screen.getByText('Unreferenced files')).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-covers')).getByText(/Can be freed/)).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-places')).getByText(/More than a fifth/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete unreferenced files' }));
await waitFor(() => expect(put).toEqual({ deleteEnabled: true }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete unreferenced files' })).toHaveAttribute('aria-pressed', 'true'));
fireEvent.click(within(screen.getByTestId('storage-reclaim-places')).getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(confirm).toEqual({ category: 'places', unreferenced: 3 }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(runs).toBe(1));
// The check runs in the background: the panel shows it running until the state says otherwise.
await waitFor(() => expect(screen.getByText('Checking…')).toBeInTheDocument());
});
it('FE-ADMIN-STOR-051b: a refused check names the reason in the operator\'s language', async () => {
server.use(
http.post('/api/admin/storage/reclaim/run', () =>
HttpResponse.json({ error: 'busy', code: 'reclaim_busy' }, { status: 409 }),
),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null } }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(screen.getByText('Another storage task is running. Try again in a moment.')).toBeInTheDocument());
});
});
@@ -38,6 +38,7 @@ import {
StatusPill,
} from '../../Settings/settingsKit'
import BackendForm from './BackendForm'
import StorageReclaimSection from './StorageReclaimSection'
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
@@ -96,6 +97,7 @@ export default function AdminStoragePanel(): React.ReactElement {
const {
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
// Open/closed only: the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh rather than reading the
@@ -627,6 +629,18 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
)}
<div className="mt-5">
<StorageReclaimSection
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
</div>
{editing && (
<BackendForm
initial={editing.initial}
@@ -0,0 +1,167 @@
import { Eraser, Loader2, ScanSearch } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { formatBytes } from '../../../utils/formatBytes';
import { relativeTime } from '../../../utils/relativeTime';
import Section from '../../Settings/Section';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import type { ReclaimCount, ReclaimView } from './reclaimModel';
interface Props {
reclaim: ReclaimView;
/** A check was asked for or is running. */
checking: boolean;
/** The switch is on its way to the server. */
saving: boolean;
onCheck: () => void;
onDelete: (enabled: boolean) => void;
/** The category whose confirmation is on its way. */
confirming: ReclaimView['lines'][number]['category'] | null;
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
}
/**
* Unreferenced files on the desktop storage panel: what the last check found,
* the switch that lets the nightly run delete them, a check on demand and the
* confirmation a category with an unusually large share of unused files waits
* for. The phone shows the same through MStorageReclaimCard, off the same view model.
*/
export default function StorageReclaimSection({
reclaim,
checking,
saving,
onCheck,
onDelete,
confirming,
onConfirm,
}: Props): React.ReactElement {
const { t, locale } = useTranslation();
const count = (c: ReclaimCount) => t('storage.usage.line', { count: c.objects, size: formatBytes(c.bytes) });
const checked = reclaim.checkedAt !== null;
return (
<Section
title={t('storage.reclaim.title')}
icon={Eraser}
hint={t('storage.reclaim.hint')}
action={
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
onClick={onCheck}
disabled={checking}
>
{checking ? (
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
) : (
<ScanSearch size={14} strokeWidth={2.2} />
)}
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
</button>
}
>
<SettingRows>
<SettingRow
label={t('storage.reclaim.delete')}
hint={t('storage.reclaim.deleteHint')}
dimmed={saving}
control={
<ToggleSwitch
on={reclaim.deleteEnabled}
onToggle={() => onDelete(!reclaim.deleteEnabled)}
label={t('storage.reclaim.delete')}
disabled={saving}
/>
}
/>
<div className="px-3.5 py-3" data-testid="storage-reclaim-summary">
<p className="m-0 text-content-faint" style={fs(11.5)}>
{reclaim.checkedAt !== null
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
: t('storage.reclaim.never')}
</p>
{reclaim.stopped && (
<p role="note" className="m-0 mt-1.5 leading-snug text-warning" style={fs(11.5)}>
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
</p>
)}
{checked && (
<div className="mt-2.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
<Figure label={t('storage.reclaim.freeable')} value={count(reclaim.freeable)} />
<Figure label={t('storage.reclaim.waiting')} value={count(reclaim.waiting)} />
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.freed)} />
</div>
)}
{checked && !reclaim.stopped && reclaim.lines.length === 0 && (
<SettingsHint className="mt-2.5">{t('storage.reclaim.nothing')}</SettingsHint>
)}
{reclaim.lines.length > 0 && (
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
{reclaim.lines.map((line) => (
<li
key={line.category}
data-testid={`storage-reclaim-${line.category}`}
className="flex flex-col gap-1"
>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
{t(`storage.category.${line.category}`)}
</span>
{line.freeable.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.freeable')}: {count(line.freeable)}
</span>
)}
{line.waiting.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.waiting')}: {count(line.waiting)}
</span>
)}
{line.skipped && (
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
)}
</div>
{line.confirmationRequired && (
<div className="flex flex-wrap items-center gap-2.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5">
<p className="m-0 min-w-0 flex-1 basis-56 leading-snug text-warning" style={fs(11.5)}>
{t('storage.reclaim.confirmNeeded')}
</p>
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
disabled={confirming === line.category}
>
{confirming === line.category && (
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
)}
{t('storage.reclaim.confirm')}
</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
</SettingRows>
</Section>
);
}
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="min-w-0 rounded-[12px] bg-surface-secondary px-3 py-2">
<p className="m-0 text-content-faint" style={fs(11)}>
{label}
</p>
<p className="m-0 mt-0.5 font-geist font-semibold tabular-nums text-content" style={fs(12.5, 'body')}>
{value}
</p>
</div>
);
}
@@ -0,0 +1,140 @@
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
import { reclaimView } from './reclaimModel';
const none = { objects: 0, bytes: 0 };
function category(patch: Partial<StorageReclaimCategoryReport> = {}): StorageReclaimCategoryReport {
return {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
...patch,
};
}
function report(
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>,
mode: StorageReclaimReport['mode'] = 'report'
): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, category({ skipped: 'not_reclaimed' })])
) as StorageReclaimReport['categories'];
return { startedAt: 1_000, finishedAt: 2_000, mode, stopped: null, categories: { ...categories, ...patch } };
}
describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off, not running and never checked', () => {
expect(reclaimView(null)).toEqual({
deleteEnabled: false,
running: false,
checkedAt: null,
stopped: null,
freeable: none,
waiting: none,
freed: none,
freedAt: null,
lines: [],
});
});
it('FE-ADMIN-STOR-RECLAIM-002: freeable is what is a week old, waiting what is younger, summed over categories', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({
covers: category({
objects: 40,
unreferenced: { objects: 5, bytes: 500 },
eligible: { objects: 3, bytes: 300 },
}),
places: category({ objects: 10, unreferenced: { objects: 1, bytes: 100 } }),
}),
});
expect(view.deleteEnabled).toBe(true);
expect(view.checkedAt).toBe(2_000);
expect(view.freeable).toEqual({ objects: 3, bytes: 300 });
expect(view.waiting).toEqual({ objects: 3, bytes: 300 });
expect(view.lines.map((line) => line.category)).toEqual(['covers', 'places']);
});
it('FE-ADMIN-STOR-RECLAIM-003: freed comes from the last run with deletion on, which a check on demand does not replace', () => {
const deleting = report(
{
files: category({
unreferenced: { objects: 2, bytes: 20 },
eligible: { objects: 2, bytes: 20 },
deleted: { objects: 2, bytes: 20 },
}),
},
'delete'
);
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: { ...deleting, finishedAt: 900 },
lastRun: report({}),
});
expect(view.freed).toEqual({ objects: 2, bytes: 20 });
expect(view.freedAt).toBe(900);
expect(view.freeable).toEqual(none);
expect(view.lines).toEqual([]);
});
it('FE-ADMIN-STOR-RECLAIM-004: a skipped category shows its reason and counts toward nothing, one never reclaimed shows nothing', () => {
const view = reclaimView({
deleteEnabled: false,
running: false,
lastDeleteRun: null,
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
});
expect(view.lines).toEqual([
{
category: 'avatars',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 9,
skipped: 'foreign_install',
},
]);
expect(view.waiting).toEqual(none);
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
});
it('FE-ADMIN-STOR-RECLAIM-005: a pass that stopped says why, and a category it stopped in keeps its partial figures', () => {
const view = reclaimView({
deleteEnabled: false,
running: false,
lastDeleteRun: null,
lastRun: {
...report({ covers: category({ skipped: 'interrupted', unreferenced: { objects: 4, bytes: 40 } }) }),
stopped: 'failed',
},
});
expect(view.stopped).toBe('failed');
expect(view.waiting).toEqual({ objects: 4, bytes: 40 });
expect(view.lines[0]).toMatchObject({ category: 'covers', skipped: 'interrupted' });
});
it('FE-ADMIN-STOR-RECLAIM-006: a category waiting for a confirmation shows even with nothing old enough yet', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
});
expect(view.lines).toEqual([
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, unreferenced: 0, skipped: null },
]);
});
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
});
});
@@ -0,0 +1,123 @@
import {
STORAGE_RECLAIM_CATEGORIES,
type StorageReclaimCategoryReport,
type StorageReclaimReport,
type StorageReclaimState,
} from '@trek/shared';
/** A number of files and their size, as the report counts them. */
export interface ReclaimCount {
objects: number;
bytes: number;
}
/** What one category of the last check has to say, when it has anything to say. */
interface ReclaimCategoryLine {
category: (typeof STORAGE_RECLAIM_CATEGORIES)[number];
/** Unused for a week or more: what the nightly run deletes once deletion is on. */
freeable: ReclaimCount;
/** Unused for less than a week: these get their week first. */
waiting: ReclaimCount;
/**
* An unusually large share of the category looks unused: nothing in it is
* deleted until an admin confirms that this is expected (a cleaned-up
* backlog, say, rather than a second installation in the same bucket).
*/
confirmationRequired: boolean;
/** Everything the check found unused in the category, the count a confirmation is given for. */
unreferenced: number;
/** Why the category was left alone, or stopped part way; null when it was checked through. */
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
}
/** The admin's view of the reclaim pass: the switch and what the last check found. */
export interface ReclaimView {
deleteEnabled: boolean;
/** A check, nightly or on demand, is running now. */
running: boolean;
/** When the last check finished, ms; null before the first one. */
checkedAt: number | null;
/** Why the last check stopped before the end; null when it ran through. */
stopped: StorageReclaimReport['stopped'];
freeable: ReclaimCount;
waiting: ReclaimCount;
/** What the last run with deletion on removed: a check on demand only reports, so it never resets this. */
freed: ReclaimCount;
/** When that run finished, ms; null when nothing ever ran with deletion on. */
freedAt: number | null;
/** Only the categories with something to free, something waiting, something to confirm, or a reason they were skipped. */
lines: ReclaimCategoryLine[];
}
const NONE: ReclaimCount = { objects: 0, bytes: 0 };
function add(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
return { objects: a.objects + b.objects, bytes: a.bytes + b.bytes };
}
function minus(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
return { objects: Math.max(0, a.objects - b.objects), bytes: Math.max(0, a.bytes - b.bytes) };
}
function freedBy(run: StorageReclaimReport | null): ReclaimCount {
if (!run) return NONE;
return STORAGE_RECLAIM_CATEGORIES.reduce(
(sum, category) => add(sum, run.categories[category]?.deleted ?? NONE),
NONE
);
}
/**
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
/**
* One category of the last check as a line, or null when it has nothing to
* say. A category the pass stopped in still counts what it found; one it
* never judged counts nothing.
*/
function lineOf(
category: ReclaimCategoryLine['category'],
report: StorageReclaimCategoryReport | undefined
): ReclaimCategoryLine | null {
if (!report || report.skipped === 'not_reclaimed') return null;
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
// What was deleted on this run no longer waits to be freed.
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
const line = {
category,
freeable,
waiting,
confirmationRequired: report.confirmationRequired,
unreferenced: report.unreferenced.objects,
skipped: report.skipped,
};
const quiet = freeable.objects === 0 && waiting.objects === 0 && !line.confirmationRequired && line.skipped === null;
return quiet ? null : line;
}
/**
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
const run = state?.lastRun ?? null;
const lines = STORAGE_RECLAIM_CATEGORIES.flatMap((category) => lineOf(category, run?.categories[category]) ?? []);
const freeable = lines.reduce((sum, line) => add(sum, line.freeable), NONE);
const waiting = lines.reduce((sum, line) => add(sum, line.waiting), NONE);
const deleteRun = state?.lastDeleteRun ?? null;
return {
deleteEnabled: state?.deleteEnabled ?? false,
running: state?.running ?? false,
checkedAt: run?.finishedAt ?? null,
stopped: run?.stopped ?? null,
freeable,
waiting,
freed: freedBy(deleteRun),
freedAt: deleteRun?.finishedAt ?? null,
lines,
};
}
@@ -53,6 +53,7 @@ const STATE: StorageAdminState = {
},
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -14,6 +14,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -1,11 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
import type {
StorageAdminState,
StorageBackend,
StorageConfig,
StorageConfigPut,
StorageReclaimConfirmRequest,
StorageTestResponse,
} from '@trek/shared'
import { adminApi } from '../../../api/client'
import { storageReclaimApi } from '../../../api/storageReclaim'
import { getApiErrorMessage, type ApiError } from '../../../types'
import { settingsDocumentOf } from './storageModel'
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
export type ReclaimFailure = { code: string } | { message: string }
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
? { code: code.code }
: { message: getApiErrorMessage(err, fallback) }
}
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
@@ -58,6 +76,12 @@ export interface StorageAdmin {
startMigration: (category: string, to: string) => Promise<string | null>
cancelMigration: (category: string) => Promise<string | null>
refreshStats: () => Promise<string | null>
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
checkReclaim: () => Promise<ReclaimFailure | null>
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
confirmReclaim: (request: StorageReclaimConfirmRequest) => Promise<ReclaimFailure | null>
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
refreshState: () => Promise<void>
/**
@@ -271,7 +295,8 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
useEffect(() => {
if (
!state?.backfills.some((b) => b.status === 'running') &&
!state?.migrations.some((m) => m.status === 'running')
!state?.migrations.some((m) => m.status === 'running') &&
!state?.reclaim?.running
)
return
const iv = setInterval(() => {
@@ -356,6 +381,71 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
}
}, [genericError])
/**
* Writes the reclaim part of the state. It counts as a deliberate state
* application, so a poll GET that was in flight before it is dropped instead
* of putting the switch back where it was.
*/
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
const prev = stateRef.current
if (!prev) return
stateSeq.current += 1
const next = { ...prev, reclaim }
stateRef.current = next
setState(next)
}, [])
const setReclaimDelete = useCallback(
async (enabled: boolean): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.setDelete(enabled))
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
},
[genericError, setReclaim],
)
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
try {
await storageReclaimApi.check()
// Running from here on: the poll above picks the report up once the pass is done.
const current = stateRef.current?.reclaim
setReclaim({
deleteEnabled: current?.deleteEnabled ?? false,
running: true,
lastRun: current?.lastRun ?? null,
lastDeleteRun: current?.lastDeleteRun ?? null,
})
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
}, [genericError, setReclaim])
const confirmReclaim = useCallback(
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.confirm(request))
return null
} catch (err: unknown) {
const failure = reclaimFailureOf(err, genericError)
// The count moved on since it was shown: show the current one before the admin confirms again.
if ('code' in failure && failure.code === 'reclaim_confirm_stale') {
try {
await refreshState()
} catch (reloadErr: unknown) {
// Without the new figure there is nothing to confirm against: say why instead.
return reclaimFailureOf(reloadErr, genericError)
}
}
return failure
}
},
[genericError, setReclaim, refreshState],
)
return {
state,
storageBusy,
@@ -376,6 +466,9 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
startMigration,
cancelMigration,
refreshStats,
setReclaimDelete,
checkReclaim,
confirmReclaim,
refreshState,
discardDraft,
}
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-PANEL-001 to -017: the storage panel logic behind both admin shells.
// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
@@ -28,6 +28,7 @@ function baseState(): StorageAdminState {
) as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -57,6 +58,9 @@ function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): Stor
startMigration: vi.fn(async () => null),
cancelMigration: vi.fn(async () => null),
refreshStats: vi.fn(async () => null),
setReclaimDelete: vi.fn(async () => null),
checkReclaim: vi.fn(async () => null),
confirmReclaim: vi.fn(async () => null),
refreshState: vi.fn(async () => {}),
discardDraft: vi.fn(async () => {}),
};
@@ -282,3 +286,48 @@ describe('migrationPromptLine', () => {
).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}');
});
});
describe('useStoragePanel: unreferenced files', () => {
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
fake.admin = makeAdmin(baseState());
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.handleReclaimDelete(true);
});
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
// A failure without a code the panel knows shows the server's message.
expect(toast.error).toHaveBeenCalledWith('disk full');
expect(result.current.reclaimSaving).toBe(false);
await act(async () => {
await result.current.handleReclaimCheck();
});
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
// A code the panel knows is shown in the operator's language.
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
expect(result.current.reclaimChecking).toBe(false);
});
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
const state = baseState();
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
fake.admin = makeAdmin(state);
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaimChecking).toBe(true);
await act(async () => {
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
});
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
expect(result.current.reclaimConfirming).toBeNull();
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
});
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaim.deleteEnabled).toBe(false);
expect(result.current.reclaim.checkedAt).toBeNull();
expect(result.current.reclaim.lines).toEqual([]);
});
});
@@ -7,11 +7,13 @@ import type {
StorageConfig,
StorageConfigPut,
StorageMigrationStatus,
StorageReclaimConfirmRequest,
} from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { formatBytes } from '../../../utils/formatBytes';
import { useToast } from '../../shared/Toast';
import { reclaimView } from './reclaimModel';
import {
adoptedMirrorFor,
computeMigrationCandidates,
@@ -31,7 +33,7 @@ import {
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel';
import { useStorageAdmin } from './useStorageAdmin';
import { useStorageAdmin, type ReclaimFailure } from './useStorageAdmin';
/** Display-name mapper for joined category lists: the raw id renders only in the badge. */
export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
@@ -165,6 +167,35 @@ export function useStoragePanel() {
if (error) toast.error(error);
};
// The click itself, until the server answered; the pass that follows shows through `reclaim.running`.
const [reclaimStarting, setReclaimStarting] = useState(false);
const [reclaimSaving, setReclaimSaving] = useState(false);
const reclaimError = (failure: ReclaimFailure) =>
'code' in failure ? t(`storage.reclaim.error.${failure.code}`) : failure.message;
const handleReclaimCheck = async () => {
setReclaimStarting(true);
const failure = await admin.checkReclaim();
setReclaimStarting(false);
if (failure) toast.error(reclaimError(failure));
};
const handleReclaimDelete = async (enabled: boolean) => {
setReclaimSaving(true);
const failure = await admin.setReclaimDelete(enabled);
setReclaimSaving(false);
if (failure) toast.error(reclaimError(failure));
};
// The category whose confirmation is on its way, so its button waits for the answer.
const [reclaimConfirming, setReclaimConfirming] = useState<StorageReclaimConfirmRequest['category'] | null>(null);
const handleReclaimConfirm = async (request: StorageReclaimConfirmRequest) => {
setReclaimConfirming(request.category);
const failure = await admin.confirmReclaim(request);
setReclaimConfirming(null);
if (failure) toast.error(reclaimError(failure));
else toast.success(t('storage.reclaim.confirmed'));
};
const reclaim = reclaimView(admin.state?.reclaim ?? null);
const reclaimChecking = reclaimStarting || reclaim.running;
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return;
setSyncPrompt((prev) => (prev === row.name ? null : prev));
@@ -371,6 +402,13 @@ export function useStoragePanel() {
migrationQueue,
handleCancelMigration,
handleRefreshStats,
reclaim,
reclaimChecking,
reclaimSaving,
reclaimConfirming,
handleReclaimCheck,
handleReclaimDelete,
handleReclaimConfirm,
handleStartBackfill,
handleCancelBackfill,
loaded,
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useFormatDate } from '../../hooks/useDateFormat';
export interface AuditEntry {
id: number;
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const { formatDateTime } = useFormatDate();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
@@ -1,6 +1,7 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
return formatDateTime(new Date(dateStr), opts);
} catch {
return dateStr;
}
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { useSettingsStore } from '../../store/settingsStore';
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
@@ -11,7 +12,13 @@ import {
normalizeStyleForProvider,
styleSettingKey,
} from '../Map/glProviders';
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
styleForProvider,
} from '../Settings/mapSettingsModel';
import { useToast } from '../shared/Toast';
import type { RoutingDefaults } from './RoutingInstanceFields';
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
date_format?: DateFormat;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
const [mapboxToken, setMapboxToken] = useState('');
const [cartoKey, setCartoKey] = useState('');
const [mapboxStyle, setMapboxStyle] = useState('');
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
defaults,
loaded,
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, operatorMapboxToken),
isSet,
save,
reset,
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -1,6 +1,6 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own — every expense maps to
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
}
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { frameLine, frameLng } from '../../utils/lngFrame';
import { useLngFrame } from '../Map/lngFrameContext';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
positions: frameLine(frame, ferrataPositions(segment)),
casing,
litCasing,
line,
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
hit,
}))
);
}, [groups, paint]);
}, [groups, paint, frame]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
@@ -1,5 +1,7 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
} catch { return '' }
}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { relativeTime } from '../../../utils/relativeTime'
import { Tooltip } from '../../shared/Tooltip'
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
const { t, language } = useTranslation()
const { formatDateTime } = useFormatDate()
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 <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
}
/**
+7 -4
View File
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no link into it.
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
{(guide.link || docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
{(guide.link || docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
{docs && (
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
)}
</div>
)}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no links into it.
const managed = useAuthStore(s => s.managed)
const closeHelp = useHelpStore(s => s.closeHelp)
const screenRoute = useScreenRoute(context?.route)
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const docs = onePerPage(context.docs)
const docs = managed ? [] : onePerPage(context.docs)
const trail = helpContextTrail(context).slice(0, -1)
const Icon = helpIcon(context.icon)
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
// No Help & Docs page on a managed install (App.tsx): guides only.
const managed = useAuthStore(s => s.managed)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
return (
<div className="trek-help-view flex flex-col gap-8">
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
</section>
)}
{!managed && (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
</ul>
)}
</section>
)}
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
@@ -102,6 +103,8 @@ beforeEach(() => {
// The time field reads time_format, so pin it: without this the cases that
// assert a 24h string depend on whichever test ran before them (#2067).
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
// A photo provider switched on for the install, so the External photos source is offered.
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
seedStore(useAddonStore, { addons: [] })
mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
import { useAddonStore } from '../../store/addonStore'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t } = useTranslation()
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Camera size={14} />
</button>
</Tooltip>
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
{/* Only where the install has a photo provider (Immich, Synology) switched
on, as on the phone: a source that can only say "nothing connected"
is not worth its width, and the other two share the row instead. */}
{photoProvidersOn && (
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
)}
</div>
{/* The gallery picker, directly below the buttons. Safari collapses
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
)}
{photoTab === 'external' && (
{photoTab === 'external' && photoProvidersOn && (
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
<div className="min-w-0">
@@ -0,0 +1,34 @@
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
import { describe, expect, it } from 'vitest';
import { journeyOpenAtEntryId } from './journeyOpenAt';
const entries = [
{ id: 3, entry_date: '2026-05-03' },
{ id: 1, entry_date: '2026-05-01' },
{ id: 2, entry_date: '2026-05-01' },
{ id: 5, entry_date: '2026-05-05' },
];
describe('journeyOpenAtEntryId', () => {
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
});
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
});
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
});
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
});
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
});
});
@@ -0,0 +1,40 @@
interface DatedEntry {
id: number | string;
entry_date: string;
}
/**
* The entry a journey opens on, or null for its first one.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now: a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*
* A link the owner published newest-first reads like a blog (#1614), so it
* opens on the latest day whether or not the trip is still running (#2851).
*
* Always the first entry OF the chosen day, not the last one before now: a day
* is read from its beginning.
*/
export function journeyOpenAtEntryId(
entries: readonly DatedEntry[],
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
): string | null {
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
const first = dated[0];
const last = dated[dated.length - 1];
if (!first || !last) return null;
let day = last.entry_date;
if (!newestFirst) {
if (today < first.entry_date || today > last.entry_date) return null;
const reached = dated.filter((e) => e.entry_date <= today);
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
}
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
return String(dayStart.id);
}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
act(() => result.current.openEntryById('99'));
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
});
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
return sync;
},
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
);
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
// Card 1 sits at the centre (150) in both positions: only the ends decide.
[0, 100, 200].forEach((left, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
const settleAt = (scrollLeft: number) => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
});
};
settleAt(300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(150);
expect(hook.result.current.activeIndex).toBe(1);
// Right to left: the scroll position runs negative from the start.
settleAt(-300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
});
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
@@ -87,6 +87,16 @@ describe('Navbar', () => {
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
render(<Navbar />);
expect(document.querySelector('[data-help-button]')).not.toBeNull();
await user.click(screen.getByText('testuser'));
expect(screen.queryByText('Help')).not.toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
const user = userEvent.setup();
render(<Navbar />);
+3 -1
View File
@@ -35,7 +35,7 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
{t('nav.settings')}
</Link>
{!managed && (
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
)}
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
@@ -1,8 +1,10 @@
import L from 'leaflet';
import { useCallback, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
import { useLngFrame } from './lngFrameContext';
import type { AssistantCamera } from './mapViewContract';
interface AssistantCameraControllerProps {
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
*/
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
const map = useMap();
// The points are framed the way the map draws them (#2975).
const frame = useLngFrame();
const getContainer = useCallback(() => map.getContainer(), [map]);
const userMovedAt = useUserMoveClock(getContainer, true);
const request = useCameraRequest(camera, userMovedAt);
useEffect(() => {
request((next) => {
request((camera) => {
const next = frame
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
: camera;
const duration = ASSISTANT_CAMERA_MS / 1000;
if (next.mode === 'pan') {
const centre = centreOf(next.points);
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
duration,
});
});
}, [map, padding, request]);
}, [map, padding, request, frame]);
return null;
}
@@ -1,7 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
import { useLngFrame } from './lngFrameContext';
import type { AssistantGhost } from './mapViewContract';
interface AssistantGhostLayerProps {
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
*/
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
const map = useMap();
// Pinned where the map draws the trip, in its longitude frame (#2975).
const frame = useLngFrame();
const handlers = useRef<GhostHandlers>({ onHover, onClick });
useLayoutEffect(() => {
handlers.current = { onHover, onClick };
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
const label = document.createElement('span');
label.textContent = ghost.name;
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
icon,
keyboard: false,
zIndexOffset: GHOST_Z_OFFSET,
})
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
.addTo(map);
return { remove: () => marker.remove() };
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
mounted.dispose();
elements.current = new Map();
};
}, [ghosts, map]);
}, [ghosts, map, frame]);
useEffect(() => {
setGhostHot(elements.current, hoveredId);
@@ -1,8 +1,11 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
const point = (event: LeafletMouseEvent) => {
const at = onRealWorld(event.latlng);
onPoint?.(line.ref, at.lat, at.lng);
};
return (
<FeatureGroup ref={group}>
<Polyline
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
return {
...line,
lines: line.lines.map((part) => frameLine(frame, part)),
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
};
}
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
const frame = useLngFrame();
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
if (!drawn?.length) return null;
return (
<>
{lines.map((line) => (
{drawn.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
+8 -4
View File
@@ -4,11 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import { useLngFrame } from './lngFrameContext'
import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
const frame = useLngFrame()
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
if (segments.length === 0) return null
return (
+5 -1
View File
@@ -4,10 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
const frame = useLngFrame()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const map = useMap()
const frame = useLngFrame()
const [layers, setLayers] = useState<PluginMapLayer[]>([])
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
// optimization — if the map has no pane API (e.g. a minimal renderer), the
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
if (layers.length === 0) return null
const pane = hasPane ? PANE : undefined
// Drawn in the map's longitude frame, with the trip (#2975).
return (
<>
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const key = `${layer.pluginId}:${layer.id}:${i}`
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
if (f.type === 'polyline' && f.points) {
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
}
if (f.type === 'polygon' && f.points) {
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
}
if (f.type === 'circle' && f.center && f.radiusM) {
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
}
return null
}))}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { pluginsApi, type PluginMapMarker } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame()
useEffect(() => {
if (tripId == null) { setMarkers([]); return }
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
return (
<>
{markers.map(mk => (
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
{(mk.label || mk.popupText || mk.url) && (
<Popup>
<div style={{ minWidth: 120, fontSize: 13 }}>
+13 -4
View File
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
const onMapClick = vi.fn()
const { unmount } = render(<MapView onMapClick={onMapClick} />)
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
expect(bound).toEqual(expect.any(Function))
// A click on the real world reaches the caller as the very event the map fired.
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapClick).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
})
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
const onMapContextMenu = vi.fn()
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
expect(bound).toEqual(expect.any(Function))
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapContextMenu).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
})
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
+46 -99
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
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 { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
const frame = useLngFrame()
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
@@ -411,14 +360,15 @@ interface MapControllerProps {
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
map.setView([center[0], frameLng(frame, center[1])], zoom)
prevCenter.current = center
}
}, [center, zoom, map])
}, [center, zoom, map, frame])
return null
}
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const frame = useLngFrame()
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
}, [map, paddingOpts, frame])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
return null
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
import { useMarkerThumbs } from './useMarkerThumbs'
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const frame = useLngFrame()
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
// 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 frame = useLngFrame()
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
// 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])
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
const photoUrls = useMarkerThumbs(places)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
const gpxTracks = useMemo(() => places.flatMap(place => {
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
}), [places, lngFrame])
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<LngFrameProvider value={lngFrame}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
/>
)}
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
positions={frameLine(lngFrame, spur.line)}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
<PluginMapLayers tripId={tripId} />
</MapContainer>
</LngFrameProvider>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
+11 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
@@ -725,7 +727,7 @@ export function MapViewGL({
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
@@ -925,7 +927,8 @@ export function MapViewGL({
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
const at = onRealWorld(e.lngLat)
onRouteClick(at.lat, at.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
@@ -944,35 +947,12 @@ export function MapViewGL({
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
@@ -1343,7 +1323,7 @@ export function MapViewGL({
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
})
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
@@ -1376,7 +1356,7 @@ export function MapViewGL({
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
@@ -1982,7 +1962,7 @@ export function MapViewGL({
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
if (fitPoints.length === 0) return
const bounds = new gl.LngLatBounds()
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
let fitted = false
const run = () => {
try {
+7 -3
View File
@@ -3,25 +3,29 @@ import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
// The route is drawn in the map's longitude frame (#2975), so its points are measured there too.
const frame = useLngFrame()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const point = map.latLngToContainerPoint([lat, frameLng(frame, lng)])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
setPosition: (lat, lng) => { marker.setLatLng([lat, frameLng(frame, lng)]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
}, [map, marker, via, controls, frame])
return null
}
@@ -1,6 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -45,7 +48,11 @@ export default function PlannerWaypointMarker({
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const frame = useLngFrame();
const position = useMemo(
() => [point.lat, frameLng(frame, point.lng)] as [number, number],
[point.lat, point.lng, frame]
);
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
@@ -67,7 +74,7 @@ export default function PlannerWaypointMarker({
if (!element) return;
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
initial: { lat: position[0], lng: position[1] },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
@@ -76,9 +83,13 @@ export default function PlannerWaypointMarker({
setPosition: ({ lat, lng }) => {
marker.setLatLng([lat, lng]);
},
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
// Saved on the real world, whichever copy of it the pin was dragged on (#2976).
onCommit: (at) => {
const { lat, lng } = onRealWorld(at);
return handlersRef.current.onMove?.(point.id, lat, lng) ?? false;
},
});
}, [map, marker, onMove, point.id, point.lat, point.lng]);
}, [map, marker, onMove, point.id, position]);
return (
<Marker
@@ -9,6 +9,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
import { frameLine } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -48,11 +50,13 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
return road && road.length >= 2 ? [frameLine(item.frame, road)] : item.arcs
}
interface TransportItem extends TransportHop {
transitSegs: TransitMapSegment[]
/** The map's longitude frame the hop was drawn in (#2975), for the road route that arrives later. */
frame: ReturnType<typeof useLngFrame>
}
interface Props {
@@ -75,16 +79,19 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const frame = useLngFrame()
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
const hop = transportHop(r, true)
const hop = transportHop(r, true, frame)
if (!hop) continue
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
const segs = hop.type === 'transit' ? getTransitMapSegments(r) : []
const transitSegs = frame ? segs.map(seg => ({ ...seg, coords: frameLine(frame, seg.coords) })) : segs
out.push({ ...hop, transitSegs, frame })
}
return out
}, [reservations])
}, [reservations, frame])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
@@ -3,6 +3,9 @@ import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
import { onRealWorld } from '../../utils/worldCopy'
import type { RoadtripVia } from '@trek/shared'
/**
@@ -64,12 +67,14 @@ export default function RoadtripViaMarkers({
return () => { map.off('zoomend', update) }
}, [map])
const frame = useLngFrame()
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
position: [via.lat, frameLng(frame, via.lng)] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
// Saved on the real world, whichever copy of it the handle was dragged on (#2976).
const at = onRealWorld(e.target.getLatLng())
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
@@ -79,7 +84,7 @@ export default function RoadtripViaMarkers({
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
})), [vias, frame])
if (!markers.length || !zoomedIn) return null
+4 -1
View File
@@ -1,4 +1,5 @@
import { Polyline } from 'react-leaflet';
import { onRealWorld } from '../../utils/worldCopy';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
@@ -31,7 +32,9 @@ export function RouteHitBand({
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
// The via is saved on the real world, whichever copy of it the route was clicked on (#2976).
const at = onRealWorld(e.latlng);
onRouteClick(at.lat, at.lng);
},
}}
/>
+11 -2
View File
@@ -4,6 +4,7 @@ import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { bboxOnRealWorld } from '../../utils/worldCopy';
/**
* The part of the planner's map on screen, told to the assistant after every
@@ -54,7 +55,11 @@ export function useReportViewLeaflet(): void {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
map.on('moveend', read);
@@ -87,7 +92,11 @@ export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady:
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
on('moveend', read);
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
})
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
// The first hotel stays booked over the whole trip and comes first in the list; the
// side trip's hotel holds the night before. The day used to start with a 716 km drive
// from the kept room to the side trip's first stop.
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
const runs = buildDayRouteRuns(3, inputs({
days,
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
accommodations: [kept, away],
optimizeFromAccommodation: true,
}))
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
})
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
const taxi = {
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
endpoints: [
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
],
} as unknown as Reservation
const runs = buildDayRouteRuns(2, inputs({
days,
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
reservations: [taxi],
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
expect(zeroLength).toEqual([])
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
+4 -1
View File
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
// that starts or ends at the hotel then adds no zero-length leg to it.
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
}
// A carrier with a located endpoint today means the day changes geography by
// air/rail/sea — the check-in hotel is then the day's destination and the
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
import { tripLngFrame } from '../../utils/lngFrame'
const YVR: [number, number] = [49.19, -123.18]
const ICN: [number, number] = [37.46, 126.44]
@@ -61,3 +62,29 @@ describe('flightGeodesy (#1411)', () => {
expect(unwrapLngs(pts)).toEqual(pts)
})
})
describe('flightGeodesy in a longitude frame (#2975)', () => {
const LAX: [number, number] = [33.94, -118.41]
const AKL: [number, number] = [-37.01, 174.79]
const frame = tripLngFrame([LAX[1], AKL[1]])
it('draws LAX to Auckland as one arc on one world copy, between the two pins as the frame places them', () => {
expect(frame).not.toBeNull()
const arcs = geodesicArcs([LAX[0], LAX[1] + 360], AKL, true, frame)
expect(arcs).toHaveLength(1)
const [arc] = arcs
expect(maxConsecutiveDeltaLng(arc)).toBeLessThan(180)
expect(arc[0][1]).toBeCloseTo(LAX[1] + 360, 5)
expect(arc[arc.length - 1][1]).toBeCloseTo(AKL[1], 5)
})
it('still copies an arc that leaves the frame, so both of its ends keep a line', () => {
const arcs = geodesicArcs(FRA, JFK, true, { seam: -30 })
expect(arcs).toHaveLength(2)
expect(arcs[1][0][1]).toBeCloseTo(arcs[0][0][1] + 360, 10)
})
it('without a frame draws what it drew before', () => {
expect(geodesicArcs(YVR, ICN, true, null)).toEqual(geodesicArcs(YVR, ICN, true))
})
})
+12 -4
View File
@@ -1,6 +1,8 @@
// Great-circle geometry for transport routes (flights, cruises, ferries),
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
import { frameLine, type LngFrame } from '../../utils/lngFrame'
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
@@ -51,11 +53,17 @@ export function unwrapLngs(points: [number, number][]): [number, number][] {
* copies), a ±360-shifted duplicate keeps both halves visible in the
* standard [-180, 180] view; GL maps repeat features themselves
* (renderWorldCopies), so the duplicate would just double the line opacity.
*
* Given a longitude frame (Leaflet, a trip across the antimeridian, #2975), the
* arc starts at `a` as the frame draws it, and the duplicate is only needed
* when the arc leaves the frame rather than the plain world.
*/
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
const arc = unwrapLngs(greatCircle(a, b))
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean, frame: LngFrame | null = null): [number, number][][] {
const arc = frame ? frameLine(frame, greatCircle(a, b)) : unwrapLngs(greatCircle(a, b))
const west = frame ? frame.seam : -180
const east = west + 360
const crosses = arc.some(([, lng]) => lng < west || lng > east)
if (!crosses || !wrapCopies) return [arc]
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
const shift = arc.some(([, lng]) => lng < west) ? 360 : -360
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { rasterTileZoom } from './glRasterLayers';
import type { MapViewProps } from './mapViewContract';
@@ -16,6 +17,11 @@ interface ViewportSource {
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
const bounds = map.getBounds();
if (!report || !bounds) return;
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
const bbox = bboxOnRealWorld({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
});
report(bbox, rasterTileZoom(map.getZoom()));
}
@@ -0,0 +1,120 @@
// The Leaflet map's bridges to the page: the events it hands out, with their
// points on the real world (#2976), and the view kept on the world copy the
// trip is drawn on (#2975).
import { wrapLongitude } from '@trek/shared';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng, type LngFrame } from '../../utils/lngFrame';
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import type { MapViewProps } from './mapViewContract';
import { bindPoiDropTarget } from './poiDropTarget';
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
export function PoiDropTarget({
onPoiDropOnRoute,
}: {
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (!onPoiDropOnRoute) return;
return bindPoiDropTarget(map.getContainer(), (x, y) => map.containerPointToLatLng([x, y]), onPoiDropOnRoute);
}, [map, onPoiDropOnRoute]);
return null;
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
export function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap();
useEffect(() => {
if (!onViewportChange) return;
const emit = () => {
const b = map.getBounds();
// A view of a world copy (or of a trip drawn past the antimeridian) asks for the real places there.
onViewportChange(
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }),
map.getZoom()
);
};
map.whenReady(emit); // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit);
map.on('zoomend', emit);
return () => {
map.off('moveend', emit);
map.off('zoomend', emit);
};
}, [map, onViewportChange]);
return null;
}
/**
* Moves the view along when the longitude frame changes under it (#2975): the trip
* is then drawn one world further east or west, and a view left where it was would
* look at an empty copy. One jump of a whole world, so nothing on screen changes.
*/
export function LngFrameFollower() {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<LngFrame | null>(frame);
useEffect(() => {
const before = prev.current;
prev.current = frame;
if (before === frame || typeof map.getCenter !== 'function') return;
const center = map.getCenter();
// Without a frame the trip is back on the real world, and so is the view.
const target = frame ? frameLng(frame, center.lng) : wrapLongitude(center.lng);
const shift = target - center.lng;
if (shift !== 0) map.setView([center.lat, target], map.getZoom(), { animate: false });
}, [frame, map]);
return null;
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null;
}
/**
* The event as the map reports it, with its point moved from a repeated world copy
* back onto the real world: a right-click on the copy east of it said 274.9 for
* -85.1, and the place was saved there (#2976).
*/
function onRealWorldEvent(e: L.LeafletMouseEvent): L.LeafletMouseEvent {
const lng = wrapLongitude(e.latlng.lng);
return lng === e.latlng.lng ? e : { ...e, latlng: L.latLng(e.latlng.lat, lng) };
}
export function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap();
useEffect(() => {
if (!onClick) return;
const handle = (e: L.LeafletMouseEvent) => onClick(onRealWorldEvent(e));
map.on('click', handle);
return () => {
map.off('click', handle);
};
}, [map, onClick]);
return null;
}
export function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap();
useEffect(() => {
if (!onContextMenu) return;
const handle = (e: L.LeafletMouseEvent) => onContextMenu(onRealWorldEvent(e));
map.on('contextmenu', handle);
return () => {
map.off('contextmenu', handle);
};
}, [map, onContextMenu]);
return null;
}
@@ -0,0 +1,51 @@
// The longitude frame of the Leaflet map, for every layer that draws in it (#2975).
// See utils/lngFrame.ts for what the frame is and why only Leaflet needs it.
import { createContext, useContext, useMemo, useState } from 'react';
import type { Place, Reservation } from '../../types';
import { tripLngFrame, type LngFrame } from '../../utils/lngFrame';
const LngFrameContext = createContext<LngFrame | null>(null);
export const LngFrameProvider = LngFrameContext.Provider;
/** The frame the map draws in, or null when it draws the plain world. */
export function useLngFrame(): LngFrame | null {
return useContext(LngFrameContext);
}
type Located = Pick<Place, 'lng'>;
function tripLngs(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): number[] {
const lngs: number[] = [];
for (const p of places) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const p of dayPlaces) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const r of reservations) for (const e of r.endpoints ?? []) if (typeof e.lng === 'number') lngs.push(e.lng);
return lngs;
}
/**
* The frame for a trip: its places, the day's places and every endpoint of every
* booking, shown or not, so switching a day or a booking on and off does not move
* the seam. The frame in use is handed back in, which keeps it while it still fits.
*/
export function useTripLngFrame(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): LngFrame | null {
const [current, setCurrent] = useState<LngFrame | null>(() =>
tripLngFrame(tripLngs(places, dayPlaces, reservations))
);
const next = useMemo(
() => tripLngFrame(tripLngs(places, dayPlaces, reservations), current),
[places, dayPlaces, reservations, current]
);
// Derived state, adjusted during render: the next render already draws in the new frame.
if (next !== current) setCurrent(next);
return next;
}
@@ -0,0 +1,39 @@
// A corridor hit dropped anywhere on the map, for every renderer.
//
// The map takes the drop, not the drawn route: in a GL renderer the line lives in the
// canvas and has no element to aim at, and the coordinate under the pointer says just
// as much once the caller projects it onto the routed geometry. The point goes out on
// the real world, whichever world copy it was dropped on (#2976).
import { onRealWorld } from '../../utils/worldCopy';
import { draggedPoiId } from './markerDrag';
/** Where a point of the container lies, in the renderer's own coordinates. */
type PointAt = (x: number, y: number) => { lat: number; lng: number };
/** Listens for POI drops on `container` and reports each one; returns the cleanup. */
export function bindPoiDropTarget(
container: HTMLElement,
pointAt: PointAt,
onPoiDropOnRoute: (osmId: string, lat: number, lng: number) => void
): () => void {
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e);
if (!osmId) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
const at = onRealWorld(pointAt(e.clientX - rect.left, e.clientY - rect.top));
onPoiDropOnRoute(osmId, at.lat, at.lng);
};
container.addEventListener('dragover', onDragOver);
container.addEventListener('drop', onDrop);
return () => {
container.removeEventListener('dragover', onDragOver);
container.removeEventListener('drop', onDrop);
};
}
@@ -88,6 +88,20 @@ describe('transportHop', () => {
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
});
it("draws the waypoints and arcs in the map's longitude frame, so the pins sit on the line (#2975)", () => {
const r = booking('flight', [
endpoint({ role: 'from', lat: 33.94, lng: -118.41 }),
endpoint({ role: 'to', sequence: 1, lat: -37.01, lng: 174.79 }),
]);
const hop = transportHop(r, true, { seam: 28 });
if (!hop) throw new Error('a flight with two located ends draws a hop');
expect(hop.from.lng).toBeCloseTo(241.59, 5);
expect(hop.to.lng).toBeCloseTo(174.79, 5);
expect(hop.arcs).toHaveLength(1);
expect(hop.arcs[0][0][1]).toBeCloseTo(241.59, 5);
expect(hop.arcs[0][hop.arcs[0].length - 1][1]).toBeCloseTo(174.79, 5);
});
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
+9 -5
View File
@@ -7,6 +7,7 @@
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
import type { Reservation, ReservationEndpoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { geodesicArcs } from './flightGeodesy';
export type TransportType =
@@ -144,14 +145,17 @@ export interface TransportHop {
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
* GL map repeats features across world copies itself, so there one continuous arc
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
* `frame` is Leaflet's longitude frame (#2975): the arcs and the waypoints come back
* drawn in it, so a pin and the line that ends at it are on the same world copy.
*/
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
export function transportHop(r: Reservation, wrapArcs: boolean, frame: LngFrame | null = null): TransportHop | null {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
const sorted = (r.endpoints || [])
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const waypoints = frame ? sorted.map((e) => ({ ...e, lng: frameLng(frame, e.lng) })) : sorted;
if (waypoints.length < 2) return null;
const from = waypoints[0];
const to = waypoints[waypoints.length - 1];
@@ -164,12 +168,12 @@ export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop |
const a = waypoints[i];
const b = waypoints[i + 1];
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs, frame)
: [
[
frameLine(frame, [
[a.lat, a.lng],
[b.lat, b.lng],
] as [number, number][],
] as [number, number][]),
];
arcs.push(...segArcs);
distanceKm += haversineKm(a, b);
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { onRealWorld } from '../../utils/worldCopy';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
@@ -245,12 +246,15 @@ export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onP
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
if (ref === null || onPlanPin(map, event)) return;
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { onRealWorld } from '../../utils/worldCopy';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
@@ -102,7 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
return onRealWorld({ lat: at.lat, lng: at.lng });
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) =>
+3 -94
View File
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
import { pdfSwitchOn } from '../../utils/pdfSwitch'
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
import { useSettingsStore } from '../../store/settingsStore'
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
import { onlyMyPlan, planAssignments } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
function renderLucideIcon(icon:LucideIcon, props = {}) {
return renderIconMarkup(
createElement(icon, props)
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
markdown.dispose()
if (signal?.aborted) return
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
if (closeBtn) closeBtn.onclick = () => overlay.remove()
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
rememberPdfSwitch(PAGE_BREAK_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
})
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
})
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
}
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
return classes.length ? ` class="${classes.join(' ')}"` : ''
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
</button>`
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
if (!button) return
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true'
button.setAttribute('aria-checked', String(on))
button.style.background = trackColour(on)
const knob = button.firstElementChild as HTMLElement | null
if (knob) knob.style.left = knobOffset(on)
onChange(on)
}
}
@@ -0,0 +1,90 @@
/**
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
* there the preview hands the export to Safari instead of offering a button that
* does nothing.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
const tr = (key: string) => key;
function open(overrides: Partial<PdfPreviewOptions> = {}) {
openPdfPreview({
html: '<html><body>plan</body></html>',
title: 'Lisbon',
tripId: 9,
tr,
breaksPerDay: true,
showTransportNotes: true,
hasTransportNotes: false,
...overrides,
});
}
function standalone(value: boolean) {
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
}
afterEach(() => {
document.getElementById('pdf-preview-overlay')?.remove();
delete (navigator as unknown as Record<string, unknown>).standalone;
localStorage.clear();
});
describe('openPdfPreview', () => {
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
open();
expect(document.getElementById('pdf-safari-link')).toBeNull();
expect(document.getElementById('pdf-safari-hint')).toBeNull();
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
const print = vi.fn();
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
const button = document.getElementById('pdf-print-btn');
expect(button?.textContent).toBe('pdf.saveAsPdf');
button?.click();
expect(print).toHaveBeenCalledTimes(1);
});
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
standalone(true);
open();
expect(document.getElementById('pdf-print-btn')).toBeNull();
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
expect(link.textContent).toBe('pdf.openInSafari');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener');
const url = new URL(link.href);
expect(url.pathname).toBe('/trips/9');
expect(url.searchParams.get('export')).toBe('pdf');
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
});
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
standalone(true);
open({ tripId: undefined });
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
expect(document.getElementById('pdf-safari-link')).toBeNull();
});
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
open({ title: '<b>Lisbon</b>' });
const overlay = document.getElementById('pdf-preview-overlay');
expect(overlay?.querySelector('b')).toBeNull();
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
document.getElementById('pdf-close-btn')?.click();
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
});
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
open();
const toggle = document.getElementById('pdf-daybreak-toggle');
expect(toggle?.getAttribute('aria-checked')).toBe('true');
toggle?.click();
expect(toggle?.getAttribute('aria-checked')).toBe('false');
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { escapeHtml } from '@trek/shared';
import { isIosStandalone } from '../../utils/iosDevice';
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK =
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
const TOGGLE_KNOB =
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
const HEADER_ACTION =
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
type Translate = (key: string, params?: Record<string, string | number>) => string;
export interface PdfPreviewOptions {
/** The finished document, shown in a sandboxed srcdoc frame. */
html: string;
title: string;
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
tripId?: number;
tr: Translate;
breaksPerDay: boolean;
showTransportNotes: boolean;
/** The transport-notes switch only shows when there is a note to hide. */
hasTransportNotes: boolean;
}
/**
* The control that saves the document. A browser prints the frame and offers
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
* Screen ignores print() altogether, so there the control opens the trip in
* Safari with the export dialog up, and a line under the header says why.
*/
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
if (tripId == null || !isIosStandalone()) {
return {
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
hint: null,
};
}
return {
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
hint: tr('pdf.safariHint'),
};
}
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
export function openPdfPreview({
html,
title,
tripId,
tr,
breaksPerDay,
showTransportNotes,
hasTransportNotes,
}: PdfPreviewOptions): void {
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div');
overlay.id = 'pdf-preview-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);display:flex;align-items:center;justify-content:center;padding:8px;';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
const card = document.createElement('div');
card.style.cssText =
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
const save = saveControl(tripId, tr);
const header = document.createElement('div');
header.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
${save.html}
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'flex:1;width:100%;border:none;';
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals';
iframe.srcdoc = html;
card.appendChild(header);
if (save.hint) {
const hint = document.createElement('p');
hint.id = 'pdf-safari-hint';
hint.style.cssText =
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
hint.textContent = save.hint;
card.appendChild(hint);
}
card.appendChild(iframe);
overlay.appendChild(card);
document.body.appendChild(overlay);
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
if (closeBtn) closeBtn.onclick = () => overlay.remove();
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
if (printBtn)
printBtn.onclick = () => {
iframe.contentWindow?.print();
};
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
rememberPdfSwitch(PAGE_BREAK_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
});
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
});
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
</button>`;
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
if (!button) return;
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true';
button.setAttribute('aria-checked', String(on));
button.style.background = trackColour(on);
const knob = button.firstElementChild as HTMLElement | null;
if (knob) knob.style.insetInlineStart = knobOffset(on);
onChange(on);
};
}

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