Compare commits

..

55 Commits

Author SHA1 Message Date
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
423 changed files with 13571 additions and 1339 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",
+1 -1
View File
@@ -34,7 +34,7 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
+4 -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,10 @@
}
],
"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/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,
+2 -3
View File
@@ -8,17 +8,16 @@
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"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,
+1 -1
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,
+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}
@@ -12,7 +12,7 @@ import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
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'
@@ -69,7 +69,7 @@ function errorText(err: unknown, fallback: string): string {
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()) })
@@ -217,7 +217,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
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"
@@ -283,7 +283,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') },
]}
/>
@@ -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,
};
@@ -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,6 +3,7 @@ import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
@@ -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';
@@ -53,6 +60,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 +163,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
defaults,
loaded,
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, operatorMapboxToken),
isSet,
save,
reset,
+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">
@@ -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)}
+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);
};
}
@@ -0,0 +1,75 @@
// FE-PLANNER-DAYDETAIL-DF-001 to FE-PLANNER-DAYDETAIL-DF-002
// The day panel's heading with "date first in day headings" on (#2921).
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildAdmin, buildDay, buildTrip } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import DayDetailPanel from './DayDetailPanel';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
const props = {
day,
days: [day],
places: [],
categories: [],
tripId: 1,
assignments: {},
reservations: [],
lat: null,
lng: null,
onClose: vi.fn(),
onAccommodationChange: vi.fn(),
};
function settings(dayDateFirst: boolean) {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', day_date_first: dayDateFirst },
});
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [] }))
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('DayDetailPanel with the date first', () => {
it('FE-PLANNER-DAYDETAIL-DF-001: the pencil sits with the name, and renaming keeps the date in view', async () => {
settings(true);
const user = userEvent.setup();
const onUpdateDayTitle = vi.fn();
render(<DayDetailPanel {...props} onUpdateDayTitle={onUpdateDayTitle} />);
const pencil = screen.getByLabelText('Edit');
expect(screen.getByText('Day in Paris').parentElement).toContainElement(pencil);
expect(screen.getByText(/June 15/).parentElement).not.toContainElement(pencil);
await user.click(pencil);
const input = await screen.findByDisplayValue('Day in Paris');
expect(screen.getByText(/June 15/)).toBeInTheDocument();
await user.clear(input);
await user.type(input, 'Vegueta');
await user.keyboard('{Enter}');
expect(onUpdateDayTitle).toHaveBeenCalledWith(1, 'Vegueta');
});
it('FE-PLANNER-DAYDETAIL-DF-002: with the name first the pencil stays on the heading, as before', async () => {
settings(false);
const user = userEvent.setup();
render(<DayDetailPanel {...props} onUpdateDayTitle={vi.fn()} />);
const pencil = screen.getByLabelText('Edit');
expect(screen.getByText('Day in Paris').parentElement).toContainElement(pencil);
await user.click(pencil);
expect(await screen.findByDisplayValue('Day in Paris')).toBeInTheDocument();
expect(screen.getByText(/June 15/)).toBeInTheDocument();
});
});
@@ -142,6 +142,28 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
) : null
const heading = dayHeadingParts(day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' }), formattedDate, dateFirst)
// The title input takes the place and size of the line it edits: the heading, or the line under it.
const titleInput = (lead: boolean) => (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onClick={e => e.stopPropagation()}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
className={`w-full border-0 border-b-[1.5px] border-content bg-transparent p-0 text-content outline-none placeholder:text-content-faint ${lead ? 'font-bold' : 'font-geist'}`}
style={lead ? fs(17, 'subtitle') : fs(12.5)}
/>
)
const renamePencil = canEditDays && onUpdateDayTitle && (
<Tooltip label={t('common.edit')}>
<button type="button" onClick={startRename} aria-label={t('common.edit')}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-content">
<Pencil size={12} strokeWidth={2} />
</button>
</Tooltip>
)
const showError = (err: unknown) => toast.error(apiErrorMessage(err) || t('common.unknownError'))
// Saving the picker: a new stay or the one being edited. Both say what went
@@ -180,18 +202,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<Calendar size={collapsed ? 16 : 20} strokeWidth={1.9} />
</span>
<div className="min-w-0 flex-1">
{editingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onClick={e => e.stopPropagation()}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
className="w-full border-0 border-b-[1.5px] border-content bg-transparent p-0 font-bold text-content outline-none placeholder:text-content-faint"
style={fs(17, 'subtitle')}
/>
{editingTitle && (collapsed || heading.nameFirst) ? (
titleInput(true)
) : collapsed ? (
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>
{heading.primary}
@@ -200,17 +212,20 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
) : (
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-bold tracking-[-0.01em] text-content" style={fs(17, 'subtitle')}>{heading.primary}</span>
{canEditDays && onUpdateDayTitle && (
<Tooltip label={t('common.edit')}>
<button type="button" onClick={startRename} aria-label={t('common.edit')}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-content">
<Pencil size={12} strokeWidth={2} />
</button>
</Tooltip>
)}
{heading.nameFirst && renamePencil}
</div>
)}
{!collapsed && heading.secondary && <div className="mt-0.5 truncate font-geist text-content-muted" style={fs(12.5)}>{heading.secondary}</div>}
{/* With the date first the name is this line, so its pencil and its input are here too (#2921). */}
{!collapsed && heading.secondary && (
editingTitle && !heading.nameFirst ? (
<div className="mt-0.5">{titleInput(false)}</div>
) : (
<div className="mt-0.5 flex min-w-0 items-center gap-1">
<span className="truncate font-geist text-content-muted" style={fs(12.5)}>{heading.secondary}</span>
{!heading.nameFirst && renamePencil}
</div>
)
)}
</div>
<HeadButton label={collapsed ? t('common.expand') : t('common.collapse')} expanded={!collapsed}
onClick={() => toggleCollapse()}>
@@ -49,12 +49,12 @@ import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, sho
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop, stopDayIds,
getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop, stopDayIds, chronoKeyOf,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '@trek/shared/roadtrip'
import { formatDate, formatTime, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
import { dayHeadingParts } from '../../utils/dayLabel'
import { dayHeadingParts, headingFit } from '../../utils/dayLabel'
import { pendingStayIds } from '../../utils/pendingStays'
import { dayWeatherAnchor } from '../../utils/dayWeather'
import { metadataWithLegPositions, moveRow, planMergedOrder, withTransportPositions } from '../../utils/mergedOrder'
@@ -935,12 +935,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(matchTo)
if (fromIdx !== -1 && toIdx !== -1) {
const keyOf = chronoKeyOf(m, dayId, getDisplayTimeForDay)
const timedInOrder = moveRow(m, fromIdx, matchTo, insertAfter).order
.map(i => {
if (i.type === 'transport') return parseTimeToMinutes(getDisplayTimeForDay(i.data, dayId))
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
return null
})
.map(i => (i.type === 'note' ? null : keyOf(i)))
.filter(t => t !== null)
const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1])
@@ -1667,11 +1664,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div className={hasDayPills ? 'min-w-0 pt-[5px]' : 'min-w-0'}>
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
<span className={`${headingFit(heading).primary} font-bold text-content`} style={fs(13.5, 'body')}>
{heading.primary}
</span>
{heading.secondary && (
<span className="flex-none whitespace-nowrap text-content-faint" style={fs(11)}>
<span className={`${headingFit(heading).secondary} text-content-faint`} style={fs(11)}>
{heading.secondary}
</span>
)}
@@ -1829,12 +1826,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Check chronological order of all timed items in the new order
const placeTime = place.place_time
if (parseTimeToMinutes(placeTime) !== null) {
const keyOf = chronoKeyOf(m, day.id, getDisplayTimeForDay)
const timedInNewOrder = newOrder
.map(i => {
if (i.type === 'transport') return parseTimeToMinutes(getDisplayTimeForDay(i.data, day.id))
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
return null
})
.map(i => (i.type === 'note' ? null : keyOf(i)))
.filter(t => t !== null)
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
if (!isChronological) {
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, PANEL_BAR } from './planParts'
import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal'
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
useExportDeepLink(() => setExportOpen(true))
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -39,7 +39,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
onDragOver={dragDisabled ? undefined : handleSidebarDragOver}
onDragLeave={dragDisabled ? undefined : handleSidebarDragLeave}
onDrop={dragDisabled ? undefined : handleSidebarDrop}
className="relative flex h-full flex-col"
className="relative flex h-full min-h-0 flex-col"
style={{ fontFamily: 'var(--font-system)' }}
>
{((!dragDisabled && sidebarDragOver) || tourFileImport.importing) && <PlacesDropOverlay {...S} />}
@@ -134,7 +134,7 @@ describe('AboutTab', () => {
it('FE-COMP-ABOUT-018: the sponsor panel links dataforest cloud wherever it is named, with both logos bundled', () => {
render(<AboutTab appVersion="1.0.0" />);
const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/?mtm_campaign=trek"]');
const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/en?mtm_campaign=trek"]');
// The logo tile, the call to action, and the brand name in the title and in the body.
expect(links).toHaveLength(4);
for (const link of links) {
+1 -1
View File
@@ -93,7 +93,7 @@ function LinkTile({
);
}
const DATAFOREST_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
const DATAFOREST_HREF = 'https://cloud.dataforest.net/en?mtm_campaign=trek';
const DATAFOREST_NAME = 'dataforest cloud';
const DATAFOREST = '#02C382'; // theme-lint-disable: brand colour (dataforest cloud)
@@ -386,6 +386,27 @@ describe('MapSettingsTab – CARTO key', () => {
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-032b: a managed instance without an operator token offers no Mapbox and no Amap tiles', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true, hasAmapKey: false });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: '', map_tile_url: '' }) });
render(<MapSettingsTab />);
expect(screen.queryByText('Mapbox GL')).not.toBeInTheDocument();
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
await user.click(screen.getByText('Select template...'));
expect(screen.queryByText('高德地图 (Amap)')).not.toBeInTheDocument();
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
});
it('FE-COMP-MAP-032c: the operator token brings Mapbox back on a managed instance', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: 'pk.operator' }) });
render(<MapSettingsTab />);
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
});
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
@@ -15,7 +15,7 @@ import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { useMapSettingsForm } from './useMapSettingsForm'
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
@@ -130,7 +130,7 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
export default function MapSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
@@ -149,7 +149,7 @@ export default function MapSettingsTab(): React.ReactElement {
{/* Provider picker — big tiles so the choice is obvious */}
<SettingRows>
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className={`grid grid-cols-1 gap-2 ${canMapbox ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
<ProviderTile
active={provider === 'leaflet'}
onClick={() => changeProvider('leaflet')}
@@ -157,15 +157,17 @@ export default function MapSettingsTab(): React.ReactElement {
name="Leaflet"
subtitle={t('settings.mapLeafletSubtitle')}
/>
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
{canMapbox && (
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
)}
<ProviderTile
active={provider === 'maplibre-gl'}
onClick={() => changeProvider('maplibre-gl')}
@@ -185,7 +187,7 @@ export default function MapSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
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
id={`${fieldId}-tile`}
@@ -0,0 +1,33 @@
// FE-MAP-MODEL-001 to FE-MAP-MODEL-004
import { describe, expect, it } from 'vitest';
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults';
import { MAP_PRESETS, mapboxOffered, mapPresetsFor } from './mapSettingsModel';
const urls = (list: { url: string }[]) => list.map((p) => p.url);
describe('mapPresetsFor', () => {
it('FE-MAP-MODEL-001: a self-hosted install offers every preset, Amap included', () => {
expect(mapPresetsFor(false, false)).toEqual(MAP_PRESETS);
});
it('FE-MAP-MODEL-002: a managed install offers Amap only where the operator set it up', () => {
const without = urls(mapPresetsFor(true, false));
expect(without).not.toContain(AMAP_ROAD);
expect(without).not.toContain(AMAP_SATELLITE);
expect(without).toHaveLength(MAP_PRESETS.length - 2);
expect(mapPresetsFor(true, true)).toEqual(MAP_PRESETS);
});
});
describe('mapboxOffered', () => {
it('FE-MAP-MODEL-003: a self-hosted install always offers Mapbox, the token field follows', () => {
expect(mapboxOffered(false, null)).toBe(true);
expect(mapboxOffered(false, '')).toBe(true);
});
it('FE-MAP-MODEL-004: a managed install offers Mapbox only with the operator token', () => {
expect(mapboxOffered(true, undefined)).toBe(false);
expect(mapboxOffered(true, '')).toBe(false);
expect(mapboxOffered(true, 'pk.operator')).toBe(true);
});
});
@@ -41,6 +41,26 @@ export const MAP_PRESETS: MapPreset[] = [
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
];
/**
* The presets this install offers. On a managed install Amap is the operator's
* service like every other keyed one: its tiles are offered only where the
* operator set Amap up (has_amap_key), so a hosted user is never pointed at a
* provider the instance does not use.
*/
export function mapPresetsFor(managed: boolean, hasAmapKey: boolean): MapPreset[] {
if (!managed || hasAmapKey) return MAP_PRESETS;
return MAP_PRESETS.filter((p) => p.url !== AMAP_ROAD && p.url !== AMAP_SATELLITE);
}
/**
* Whether Mapbox GL can be picked. A managed install brings the token with the
* instance and offers no field for one, so without an operator token the
* choice would only lead to a map that never loads.
*/
export function mapboxOffered(managed: boolean, token: string | null | undefined): boolean {
return !managed || !!token;
}
export type MapProvider = 'leaflet' | GlMapProvider;
export function normalizeProvider(value: unknown): MapProvider {
@@ -7,7 +7,15 @@ import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
previewPlace,
slotStyle,
styleForProvider,
} from './mapSettingsModel';
export interface MapSettingsFormOptions {
/**
@@ -32,6 +40,7 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
const [provider, setProvider] = useState<MapProvider>(initialProvider);
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '');
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '');
const [mapboxStyle, setMapboxStyle] = useState<string>(
@@ -94,6 +103,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
return {
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, settings.mapbox_access_token),
saving,
provider,
changeProvider,
@@ -239,7 +239,7 @@ describe('ReleaseNoticeModal', () => {
const links = screen.getAllByRole('link', { name: 'dataforest cloud' });
expect(links).toHaveLength(2);
for (const link of links) {
expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/?mtm_campaign=trek');
expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/en?mtm_campaign=trek');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
}
@@ -38,7 +38,7 @@ function SteamingCoffee() {
}
const SPONSOR = 'dataforest cloud';
const SPONSOR_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
const SPONSOR_HREF = 'https://cloud.dataforest.net/en?mtm_campaign=trek';
/** The sponsor's name is a brand and reads the same in every language, so wherever a line names it, it links. */
function withSponsorLink(text: string): React.ReactNode {
@@ -1,8 +1,8 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourGpxPreviewTrack } from '@trek/shared';
import type { TourFileLeftOut, TourGpxPreviewTrack } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
@@ -27,10 +27,10 @@ function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrac
};
}
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
function gpxImport(tracks: TourGpxPreviewTrack[], leftOut?: TourFileLeftOut): TourGpxImport {
return {
importing: false,
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks, leftOut }],
confirm: vi.fn(),
cancel: vi.fn(),
pick: vi.fn(),
@@ -63,19 +63,54 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
/>
);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
expect(current.confirm).toHaveBeenCalledWith(
{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' },
{ points: false }
);
});
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
const walk = gpxImport([track()]);
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' }, { points: false });
unmount();
const hike = gpxImport([track()]);
render(<TourGpxPreviewDialog gpxImport={hike} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
});
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
// Added as places, they are not listed as left out.
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
const button = screen.getByTestId('gpx-preview-import');
expect(button).toBeEnabled();
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
expect(screen.queryByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeNull();
fireEvent.click(button);
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
});
@@ -1,6 +1,6 @@
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
import { useId, useMemo } from 'react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import ToggleSwitch from '../Settings/ToggleSwitch';
import {
DialogButton,
DialogFooter,
@@ -98,8 +99,8 @@ function PreviewTrackRow({
);
}
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
/** What all the files held besides their lines, summed by kind. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
const sum = { points: 0, areas: 0, other: 0 };
for (const { leftOut } of files) {
if (!leftOut) continue;
@@ -107,18 +108,46 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
sum.areas += leftOut.areas;
sum.other += leftOut.other;
}
return sum.points + sum.areas + sum.other > 0 ? sum : null;
return sum;
}
/**
* IO-02: what a KML or KMZ file held besides its lines, named before the
* import so nothing goes missing unseen: a dashed card with each kind as a
* counted badge, and why in the info dot.
* #2969: the single points of the files (GPX waypoints, KML pins), which no
* tour is made of, offered as places of the trip. On by default: a file with
* a route and its stops is usually meant as both.
*/
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
const { t } = useTranslation();
const leftOut = leftOutOf(files);
if (!leftOut) return null;
const hintId = useId();
return (
<div
data-testid="gpx-preview-points"
className="mt-2 flex items-center gap-2.5 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2"
>
<MapPin size={14} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex flex-wrap items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.import.pointsAsPlaces')}
<SoftPill>{t('tours.import.leftOutPoints', { count })}</SoftPill>
</span>
<span id={hintId} className="text-content-muted" style={fs(11.5, 'body')}>
{t('tours.import.pointsAsPlacesHint')}
</span>
</div>
<ToggleSwitch on={on} onToggle={onToggle} label={t('tours.import.pointsAsPlaces')} describedBy={hintId} />
</div>
);
}
/**
* IO-02: what a file held besides its lines and does not take in, named
* before the import so nothing goes missing unseen: a dashed card with each
* kind as a counted badge, and why in the info dot. Its points only while
* they are not added as places.
*/
function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
const { t } = useTranslation();
if (leftOut.points + leftOut.areas + leftOut.other === 0) return null;
const kinds = [
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
@@ -170,6 +199,29 @@ export default function TourGpxPreviewDialog({
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
/** A fresh line needs its type complete; a file of known lines can still be confirmed for its points alone. */
function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean): boolean {
return fresh > 0 ? typeComplete : addsPoints;
}
/** The points offered as places, and below them whatever else stays out. */
function PreviewExtras({
leftOut,
withPoints,
onTogglePoints,
}: {
leftOut: TourFileLeftOut;
withPoints: boolean;
onTogglePoints: () => void;
}) {
return (
<>
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
</>
);
}
function PreviewDialog({
gpxImport,
files,
@@ -183,6 +235,9 @@ function PreviewDialog({
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const choices = useGpxImportChoices(initial);
const leftOut = useMemo(() => leftOutOf(files), [files]);
const [withPoints, setWithPoints] = useState(true);
const addsPoints = withPoints && leftOut.points > 0;
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -194,7 +249,8 @@ function PreviewDialog({
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
const canImport = canConfirm(fresh, addsPoints, importMissing(choices.chosen) === null) && !gpxImport.importing;
const confirmLabel = fresh > 0 ? t('tours.import.previewConfirm', { count: fresh }) : t('tours.import.pointsConfirm');
const fields = importFields(choices.chosen);
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
@@ -231,9 +287,9 @@ function PreviewDialog({
data-testid="gpx-preview-import"
icon={<FileDown size={14} strokeWidth={2} />}
disabled={!canImport}
onClick={() => void gpxImport.confirm(fields)}
onClick={() => void gpxImport.confirm(fields, { points: addsPoints })}
>
{t('tours.import.previewConfirm', { count: fresh })}
{confirmLabel}
</DialogButton>
</DialogFooter>
}
@@ -258,8 +314,8 @@ function PreviewDialog({
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewLeftOut files={files} />
{fresh === 0 && (
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
{fresh === 0 && !addsPoints && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.import.previewAllDuplicates')}
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { placesApi } from '../../api/client'
import { tourGpxApi } from '../../api/tourGpx'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
@@ -457,6 +458,7 @@ describe('ToursSidebar', () => {
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
@@ -469,9 +471,11 @@ describe('ToursSidebar', () => {
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
// The pins are offered as places (#2969); only the area stays out.
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).not.toHaveTextContent('pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
@@ -479,6 +483,8 @@ describe('ToursSidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(toast.success).toHaveBeenCalledWith('3 places added')
vi.restoreAllMocks()
})
@@ -121,7 +121,7 @@ describe('a ride taken from a GPX file (HOR-02)', () => {
expect(gpxImport.confirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' }, { points: false });
});
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
@@ -5,7 +5,9 @@ import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../tests/helpers/store';
import { placesApi } from '../../api/client';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { useTourGpxImport } from './useTourGpxImport';
// The toasts are caught at useToast, so no window global has to be set for them.
@@ -67,3 +69,66 @@ describe('useTourGpxImport without a line in the file', () => {
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
});
});
describe('useTourGpxImport with points beside the lines (#2969)', () => {
const route = new File(['<gpx/>'], 'sygic.gpx');
const pins = new File(['<kml/>'], 'pins.kml');
const line = {
name: 'Route',
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
point_count: 2,
distance_km: 1,
elevation_gain_m: null,
elevation_loss_m: null,
duplicate: false,
};
beforeEach(() => {
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
});
const previewed = async (preview: (picked: File) => object) => {
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
await act(() => hook.result.current.importFiles([route, pins]));
return hook;
};
it('adds the points of each file as places through the places import, waypoints only', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
const { result } = await previewed((picked) => ({
tracks: [line],
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
});
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const { result } = await previewed(() => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: 3, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }));
expect(importGpx).not.toHaveBeenCalled();
expect(tourRepo.importGpx).not.toHaveBeenCalled();
});
it('names a file whose points could not be added, and still takes in its tour', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
const { result } = await previewed((picked) => ({
tracks: [line],
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
});
});
+71 -16
View File
@@ -40,10 +40,62 @@ function fileErrorMessage(error: unknown, name: string, t: Translate): string {
export interface GpxPreviewFile {
file: File;
tracks: TourGpxPreviewTrack[];
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
/** What the file held besides its lines, which no tour is made of: GPX waypoints, KML pins, areas and the rest. */
leftOut?: TourFileLeftOut;
}
/** What to do with the previewed files besides their lines. */
export interface TourImportExtras {
/** Add each file's points (GPX waypoints, KML pins) as places of the trip (#2969). */
points?: boolean;
}
interface ToursImported {
tours: TourListItem[];
skipped: number;
caution: boolean;
}
/** Each file with a line the trip does not hold yet, imported as tours; a file that fails is handed to `onError`. */
async function importTours(
tripId: number,
files: readonly GpxPreviewFile[],
fields: TourGpxImportFields,
onError: (error: unknown, file: File) => void
): Promise<ToursImported> {
const sum: ToursImported = { tours: [], skipped: 0, caution: false };
for (const { file, tracks } of files) {
if (tracks.every((track) => track.duplicate)) continue;
try {
const result = await tourRepo.importGpx(tripId, file, fields);
sum.tours.push(...result.tours);
sum.skipped += result.skipped;
sum.caution ||= result.caution;
} catch (error: unknown) {
onError(error, file);
}
}
return sum;
}
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
async function importPoints(
tripId: number,
files: readonly GpxPreviewFile[],
onError: (file: File) => void
): Promise<number> {
let places = 0;
for (const { file, leftOut } of files) {
if (!leftOut?.points) continue;
try {
places += await tourRepo.importFilePoints(tripId, file);
} catch {
onError(file);
}
}
return places;
}
interface TourGpxImportOptions {
tripId: number;
canEdit: boolean;
@@ -115,26 +167,29 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
[canEdit, fileError, importing, onNoTrack, preview, t, toast, tripId]
);
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
/**
* The previewed files imported as the chosen type, summed up in one set of
* toasts. With `points`, what each file holds as single points comes in as
* places as well, so a file with a route and its stops loses neither (#2969).
* A file whose every line the trip already holds is not sent to the Tours
* import at all, only for its points.
*/
const confirm = useCallback(
async (fields: TourGpxImportFields) => {
async (fields: TourGpxImportFields, extras: TourImportExtras = {}) => {
if (!canEdit || !preview || importing) return;
setImporting(true);
const imported: TourListItem[] = [];
let skipped = 0;
let caution = false;
try {
for (const { file } of preview) {
try {
const result = await tourRepo.importGpx(tripId, file, fields);
imported.push(...result.tours);
skipped += result.skipped;
caution ||= result.caution;
} catch (error: unknown) {
fileError(error, file);
}
}
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
const places = extras.points
? await importPoints(tripId, preview, (file) =>
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
)
: 0;
setPreview(null);
if (places > 0) {
if (imported.length === 0) await refreshPlaces(tripId);
toast.success(t('tours.import.pointsImported', { count: places }));
}
if (imported.length > 0) {
// The server's place:created skips this socket, so the places are read
// again here for the new ones and their lines. Only the places: a whole
+27
View File
@@ -232,3 +232,30 @@ describe('help registry: helpers', () => {
expect(docsRoute({ slug: 'Creating-a-Trip', anchor: 'fields' })).toBe('/help/Creating-a-Trip#fields')
})
})
describe('help on a managed install', () => {
it('leaves out the guides and screens about what the operator runs', async () => {
const { useAuthStore } = await import('../store/authStore')
const { useAddonStore } = await import('../store/addonStore')
const reg = await import('./registry')
useAuthStore.setState({ managed: true })
try {
expect(reg.getHelpGuide('create-backup')).toBeNull()
expect(reg.getHelpGuide('instance-keys')).toBeNull()
expect(reg.getHelpContext('admin-backup')).toBeNull()
expect(reg.allHelpGuides().some(g => g.context === 'admin-storage')).toBe(false)
expect(reg.getHelpGuide('files-sync')).toBeNull()
// Integrations follow whether the instance has them on.
useAddonStore.setState({ addons: [] })
expect(reg.getHelpGuide('connect-dawarich')).toBeNull()
useAddonStore.setState({ addons: [{ id: 'dawarich', enabled: true } as never] })
expect(reg.getHelpGuide('connect-dawarich')).not.toBeNull()
// What a hosted admin can still do keeps its guide.
expect(reg.getHelpGuide('create-user')).not.toBeNull()
} finally {
useAuthStore.setState({ managed: false })
useAddonStore.setState({ addons: [] })
}
expect(reg.getHelpGuide('create-backup')).not.toBeNull()
})
})
+60 -7
View File
@@ -1,4 +1,6 @@
import type { HelpContext, HelpGuide } from './types'
import { useAddonStore } from '../store/addonStore'
import { useAuthStore } from '../store/authStore'
import { dashboardContext, dashboardGuides } from './contexts/dashboard'
import { vacayContext, vacayGuides } from './contexts/vacay'
import { atlasContext, atlasGuides } from './contexts/atlas'
@@ -74,36 +76,87 @@ const GUIDE_LIST: HelpGuide[] = [
export const HELP_CONTEXTS: ReadonlyMap<string, HelpContext> = new Map(CONTEXT_LIST.map(c => [c.id, c]))
export const HELP_GUIDES: ReadonlyMap<string, HelpGuide> = new Map(GUIDE_LIST.map(g => [g.id, g]))
/**
* What a managed install leaves out: the admin screens it hides (AdminPage) and
* the guides about what the operator runs there, such as keys, OIDC, place
* search, routing, backups, storage, releases and plugins. A guide that walks
* somebody through a panel they cannot open is worse than none.
*/
const MANAGED_HIDDEN_CONTEXTS: ReadonlySet<string> = new Set([
'admin-plugins', 'admin-storage', 'admin-github', 'admin-backup', 'settings-plugins',
])
const MANAGED_HIDDEN_GUIDES: ReadonlySet<string> = new Set([
'oidc', 'instance-keys', 'places-transit', 'rotate-jwt-secret', 'own-routing-engine',
'install-plugin', 'storage-backends', 'release-history', 'create-backup', 'auto-backup',
'plugin-settings', 'files-sync', 'files-sync-settings', 'document-providers',
])
/**
* Guides about an integration, shown on a managed install only while it is on
* there: the operator decides which integrations the instance has, and a guide
* to connect one it does not offer leads nowhere.
*/
const addonOn = (id: string) => (): boolean => useAddonStore.getState().isEnabled(id)
const MANAGED_FEATURE_GUIDES: Readonly<Record<string, () => boolean>> = {
'connect-dawarich': addonOn('dawarich'),
'dawarich-countries': addonOn('dawarich'),
'dawarich-wishes': addonOn('dawarich'),
'dawarich-stays': addonOn('dawarich'),
'journey-day-stays': addonOn('dawarich'),
'map-dawarich-trail': addonOn('dawarich'),
'connect-airtrail': addonOn('airtrail'),
'airtrail-import': addonOn('airtrail'),
'photo-providers': addonOn('memories'),
'mcp-oauth': addonOn('mcp'),
'api-keys': addonOn('mcp'),
'mcp-tokens-admin': addonOn('mcp'),
'tour-connect-wanderer': () => useAddonStore.getState().toursWanderer,
'tour-wanderer-watch': () => useAddonStore.getState().toursWanderer,
'tour-import-wanderer': () => useAddonStore.getState().toursWanderer,
}
/** Read at call time: `managed` arrives with app-config, after the registry loaded. */
const isManaged = (): boolean => useAuthStore.getState().managed
const shownContext = (c: HelpContext): boolean => !(isManaged() && MANAGED_HIDDEN_CONTEXTS.has(c.id))
const shownGuide = (g: HelpGuide): boolean => {
if (!isManaged()) return true
if (MANAGED_HIDDEN_GUIDES.has(g.id) || MANAGED_HIDDEN_CONTEXTS.has(g.context)) return false
const feature = MANAGED_FEATURE_GUIDES[g.id]
return feature ? feature() : true
}
export function getHelpContext(id: string | null | undefined): HelpContext | null {
return id ? (HELP_CONTEXTS.get(id) ?? null) : null
const context = id ? HELP_CONTEXTS.get(id) : undefined
return context && shownContext(context) ? context : null
}
export function getHelpGuide(id: string | null | undefined): HelpGuide | null {
return id ? (HELP_GUIDES.get(id) ?? null) : null
const guide = id ? HELP_GUIDES.get(id) : undefined
return guide && shownGuide(guide) ? guide : null
}
/** The guides of a context, in the order the context lists them. */
export function guidesFor(context: HelpContext): HelpGuide[] {
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined)
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined && shownGuide(g))
}
export function allHelpGuides(): HelpGuide[] {
return GUIDE_LIST
return isManaged() ? GUIDE_LIST.filter(shownGuide) : GUIDE_LIST
}
/** Every screen that has help, in registration order, for the screen switcher. */
export function allHelpContexts(): HelpContext[] {
return CONTEXT_LIST
return isManaged() ? CONTEXT_LIST.filter(shownContext) : CONTEXT_LIST
}
/** The screens that stand on their own; their sub-screens hang under them in the switcher. */
export function topLevelHelpContexts(): HelpContext[] {
return CONTEXT_LIST.filter(c => !c.parent)
return CONTEXT_LIST.filter(c => !c.parent && shownContext(c))
}
/** The sub-screens of a screen, in registration order. */
export function childHelpContexts(parentId: string): HelpContext[] {
return CONTEXT_LIST.filter(c => c.parent === parentId)
return CONTEXT_LIST.filter(c => c.parent === parentId && shownContext(c))
}
/** The screen and the screens above it, outermost first, for a breadcrumb. */
@@ -0,0 +1,49 @@
/**
* FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
* once, the way Safari lands after the iOS Home Screen app handed the PDF over.
*/
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import { useExportDeepLink } from './useExportDeepLink';
function setup(entry: string) {
const openExport = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[entry]}>{children}</MemoryRouter>
);
const { result, rerender } = renderHook(
() => {
useExportDeepLink(openExport);
return useLocation();
},
{ wrapper }
);
return { openExport, result, rerender };
}
describe('useExportDeepLink', () => {
it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
expect(openExport).toHaveBeenCalledTimes(1);
expect(result.current.pathname).toBe('/trips/7');
expect(result.current.search).toBe('?tab=plan');
rerender();
expect(openExport).toHaveBeenCalledTimes(1);
});
it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
const { openExport, result } = setup('/trips/7?tab=plan');
expect(openExport).not.toHaveBeenCalled();
expect(result.current.search).toBe('?tab=plan');
});
it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
const { openExport, result } = setup('/trips/7?export=gpx');
expect(openExport).not.toHaveBeenCalled();
expect(result.current.search).toBe('?export=gpx');
});
});
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useEffectEvent } from 'react';
import { useSearchParams } from 'react-router';
import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
/**
* Opens the trip's export when the address asks for it (`?export=pdf`), the way
* the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
* once read, so a reload or a copied link does not open the dialog again.
*/
export function useExportDeepLink(openExport: () => void): void {
const [searchParams, setSearchParams] = useSearchParams();
const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
// The shells pass a fresh closure on every render; the request is answered once.
const open = useEffectEvent(openExport);
useEffect(() => {
if (!requested) return;
open();
setSearchParams(
(p) => {
p.delete(EXPORT_PARAM);
return p;
},
{ replace: true }
);
}, [requested, setSearchParams]);
}
+1 -1
View File
@@ -88,7 +88,7 @@ export default function MAdmin() {
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Blocks },
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle }]),
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive }]),
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
@@ -63,6 +63,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -115,13 +116,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
{addon.id === 'documents' && addon.enabled && <MProviderShelf options={documentProviderOptions} />}
{addon.id === 'tours' && addon.enabled && (
<>
<MSubRow
providerIcon={WandererMark}
title={t('admin.toursWanderer.title')}
subtitle={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
{wandererOffered && (
<MSubRow
providerIcon={WandererMark}
title={t('admin.toursWanderer.title')}
subtitle={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)}
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<MSubRow
key={key}
@@ -7,7 +7,7 @@ import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../
import { weekStartOptions } from '../../../utils/calendarWeek'
import { withTileApiKey } from '../../../utils/tileUrl'
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
import { useDefaultUserSettings, type Defaults } from '../../../components/Admin/useDefaultUserSettings'
import MToggle from '../../components/MToggle'
import MSegmented from '../../components/MSegmented'
@@ -22,7 +22,7 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
export default function MAdminDefaultUserSettings(): 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: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
@@ -80,14 +80,14 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
]
const providerOptions: { value: string; label: React.ReactNode }[] = [
{ 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') },
]
const currencyLabel = defaults.default_currency
? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
: t('settings.currency')
const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
@@ -302,7 +302,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
title={t('settings.mapTemplate')}
value={mapTileUrl}
onSelect={pickTilePreset}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
options={presets.map((p) => ({ value: p.url, label: p.name }))}
/>
<MSetPickerSheet
@@ -0,0 +1,66 @@
import type { useAdmin } from '../../../pages/admin/useAdmin';
import type { TranslationFn } from '../../../types';
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput } from './MAdminUi';
interface MAdminOidcCardProps {
admin: ReturnType<typeof useAdmin>;
t: TranslationFn;
onSave: () => void;
}
/** OIDC / SSO, as on the desktop settings tab. The settings section leaves it out on a managed install. */
export default function MAdminOidcCard({ admin, t, onSave }: MAdminOidcCardProps) {
const { oidcConfig, setOidcConfig, savingOidc } = admin;
return (
<MAdminCard>
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
<div className="space-y-3">
<MAdminField label={t('admin.oidcDisplayName')}>
<MAdminInput
type="text"
value={oidcConfig.display_name}
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
placeholder={t('admin.oidcDisplayNamePlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
<MAdminInput
type="url"
value={oidcConfig.issuer}
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
</MAdminField>
<MAdminField
label={t('admin.oidcDiscoveryUrl')}
hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}
>
<MAdminInput
type="url"
value={oidcConfig.discovery_url}
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientId')}>
<MAdminInput
type="text"
value={oidcConfig.client_id}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientSecret')}>
<MAdminInput
type="password"
value={oidcConfig.client_secret}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
/>
</MAdminField>
<MAdminButton busy={savingOidc} onClick={onSave}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
);
}
@@ -6,6 +6,7 @@ import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
import MAdminOidcCard from './MAdminOidcCard'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import {
MAdminButton,
@@ -31,7 +32,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
placeShadowEnabled,
oidcConfig, setOidcConfig, savingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, oidcConfigured, requireMfa,
@@ -134,6 +134,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{t('admin.passkey.notConfigured')}
</p>
)}
{/* The relying party is the instance's address, which the operator owns on a
managed install (server managed.ts), same as the desktop tab. */}
{!managed && (
<div className="space-y-3">
<MAdminField label={t('admin.passkey.rpId')} hint={t('admin.passkey.rpIdHint')}>
<MAdminInput
@@ -155,6 +158,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{t('common.save')}
</MAdminButton>
</div>
)}
</MAdminCard>
{/* Require 2FA */}
@@ -187,7 +191,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
</div>
</MAdminCard>
{/* API keys */}
{/* API keys, place search and routing: the operator's on a managed install,
same as the desktop tab. Share place details has its own section there. */}
{!managed && (
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
<div className="space-y-3">
@@ -379,55 +385,10 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
)}
</div>
</MAdminCard>
)}
{/* OIDC / SSO */}
<MAdminCard>
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
<div className="space-y-3">
<MAdminField label={t('admin.oidcDisplayName')}>
<MAdminInput
type="text"
value={oidcConfig.display_name}
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
placeholder={t('admin.oidcDisplayNamePlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
<MAdminInput
type="url"
value={oidcConfig.issuer}
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
</MAdminField>
<MAdminField label={t('admin.oidcDiscoveryUrl')} hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}>
<MAdminInput
type="url"
value={oidcConfig.discovery_url}
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientId')}>
<MAdminInput
type="text"
value={oidcConfig.client_id}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientSecret')}>
<MAdminInput
type="password"
value={oidcConfig.client_secret}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
/>
</MAdminField>
<MAdminButton busy={savingOidc} onClick={saveOidc}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* OIDC / SSO: the operator's on a managed install, as on the desktop tab. */}
{!managed && <MAdminOidcCard admin={admin} t={t} onSave={saveOidc} />}
{/* Danger zone */}
<MAdminCard className="border-[color:color-mix(in_srgb,var(--m-st-danger)_28%,transparent)]">
@@ -17,6 +17,7 @@ import MToggle from '../../components/MToggle'
import MStorageTestResult from '../../components/MStorageTestResult'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import MConfirmSheet from '../settings/MConfirmSheet'
import MStorageReclaimCard from './MStorageReclaimCard'
import { MSetSelectRow } from '../settings/MSettingsUi'
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
@@ -172,6 +173,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
const {
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
const [categoryPicker, setCategoryPicker] = useState<StorageCategory | null>(null)
@@ -565,6 +567,16 @@ export default function MAdminStoragePanel(): React.ReactElement {
</div>
)}
<MStorageReclaimCard
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
<MConfirmSheet
open={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
@@ -0,0 +1,140 @@
import React from 'react';
import type { ReclaimCount, ReclaimView } from '../../../components/Admin/storage/reclaimModel';
import { fs } from '../../../components/shared/DialogShell';
import { useTranslation } from '../../../i18n';
import { formatBytes } from '../../../utils/formatBytes';
import { relativeTime } from '../../../utils/relativeTime';
import MToggle from '../../components/MToggle';
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminRow } from './MAdminUi';
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 phone's storage panel, off the view model StorageReclaimSection shows on desktop. */
export default function MStorageReclaimCard({
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 (
<MAdminCard>
<MAdminCardHead
title={t('storage.reclaim.title')}
hint={t('storage.reclaim.hint')}
trailing={
<MAdminButton variant="ghost" busy={checking} onClick={onCheck}>
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
</MAdminButton>
}
/>
<MAdminRow
first
title={t('storage.reclaim.delete')}
hint={t('storage.reclaim.deleteHint')}
trailing={
<MToggle
checked={reclaim.deleteEnabled}
onChange={onDelete}
disabled={saving}
ariaLabel={t('storage.reclaim.delete')}
/>
}
/>
<div className="border-t border-[color:var(--m-rowbr)] pt-[11px]" data-testid="m-storage-reclaim-summary">
<p className="font-geist text-m-muted" style={fs(10)}>
{reclaim.checkedAt !== null
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
: t('storage.reclaim.never')}
</p>
{reclaim.stopped && (
<p role="note" className="mt-1 font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
</p>
)}
{checked && (
<div className="mt-2 space-y-1">
<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 && (
<p className="mt-2 font-geist text-m-muted" style={fs(10)}>
{t('storage.reclaim.nothing')}
</p>
)}
{reclaim.lines.length > 0 && (
<div className="mt-2 space-y-1.5">
{reclaim.lines.map((line) => (
<div key={line.category} data-testid={`m-storage-reclaim-${line.category}`}>
<p className="font-bold text-m-ink" style={fs(12, 'body')}>
{t(`storage.category.${line.category}`)}
</p>
{line.skipped && (
<p className="font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
{t(`storage.reclaim.skip.${line.skipped}`)}
</p>
)}
{line.freeable.objects > 0 && (
<p className="font-geist text-m-muted" style={fs(10)}>
{t('storage.reclaim.freeable')}: {count(line.freeable)}
</p>
)}
{line.waiting.objects > 0 && (
<p className="font-geist text-m-muted" style={fs(10)}>
{t('storage.reclaim.waiting')}: {count(line.waiting)}
</p>
)}
{line.confirmationRequired && (
<div className="mt-1 flex items-center gap-2">
<p className="min-w-0 flex-1 font-geist text-m-muted" style={fs(10)}>
{t('storage.reclaim.confirmNeeded')}
</p>
<MAdminButton
variant="ghost"
busy={confirming === line.category}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
>
{t('storage.reclaim.confirm')}
</MAdminButton>
</div>
)}
</div>
))}
</div>
)}
</div>
</MAdminCard>
);
}
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="font-geist text-m-muted" style={fs(11)}>
{label}
</span>
<span className="font-geist font-bold tabular-nums text-m-ink" style={fs(11)}>
{value}
</span>
</div>
);
}
@@ -51,7 +51,7 @@ export default function MSettings() {
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []),
...(hasPlugins ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
{ id: 'account', label: t('settings.tabs.account'), icon: User },
// Same call as the desktop page: About is about the project — what TREK is,
@@ -116,7 +116,7 @@ export default function MSettings() {
{active.id === 'notifications' && <MSettingsNotifications />}
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
{/* Per-plugin settings still reuse the existing responsive tab. */}
{active.id === 'plugins' && hasPlugins && <MSettingsPlugins />}
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
{active.id === 'offline' && <MSettingsOffline />}
{active.id === 'account' && <MSettingsAccount />}
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
@@ -6,7 +6,7 @@ import { MapView } from '../../../components/Map/MapView'
import ErrorBoundary from '../../../components/shared/ErrorBoundary'
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy'
import { defaultStyleForProvider, getStylePresets } from '../../../components/Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import { PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import { useMapSettingsForm } from '../../../components/Settings/useMapSettingsForm'
import { withTileApiKey } from '../../../utils/tileUrl'
import MToggle from '../../components/MToggle'
@@ -20,7 +20,7 @@ import MSetPickerSheet from './MSetPickerSheet'
export default function MSettingsMap() {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
managed, presets: tilePresets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
@@ -29,7 +29,7 @@ export default function MSettingsMap() {
const providers: { id: MapProvider; name: string; sub: string; icon: typeof Layers }[] = [
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
{ id: 'mapbox-gl', name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box },
...(canMapbox ? [{ id: 'mapbox-gl' as const, name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box }] : []),
{ id: 'maplibre-gl', name: 'MapLibre GL', sub: t('settings.mapMapLibreSubtitle'), icon: Globe2 },
]
@@ -73,7 +73,7 @@ export default function MSettingsMap() {
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapTemplate')}</MSetEyebrow>
<MSetSelectRow
label={MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
label={tilePresets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
trailing={chevron}
onClick={() => setPresetOpen(true)}
/>
@@ -221,7 +221,7 @@ export default function MSettingsMap() {
title={t('settings.mapTemplate')}
value={mapTileUrl}
onSelect={setMapTileUrl}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
options={tilePresets.map((p) => ({ value: p.url, label: p.name }))}
/>
<MSetPickerSheet
@@ -1,6 +1,6 @@
import {
TRANSPORT_TYPES, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints, hidesOnMiddleDay,
parseTimeToMinutes,
TRANSPORT_TYPES, chronoKeyOf, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints,
hidesOnMiddleDay, parseTimeToMinutes,
} from '../../../../utils/dayMerge'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
@@ -310,12 +310,8 @@ export function breaksChronology(
dayId: number,
getDisplayTime: (r: Reservation, dayId: number) => string | null,
): boolean {
const times = order
.map(it => {
if (it.type === 'place') return parseTimeToMinutes((it.data as Assignment).place?.place_time)
if (it.type === 'note') return parseTimeToMinutes((it.data as DayNote).time)
return parseTimeToMinutes(getDisplayTime(it.data as Reservation, dayId))
})
.filter((m): m is number => m != null)
// The key the day is drawn by, so flights put in the order they happen across zones still read as in order (#2929).
const keyOf = chronoKeyOf(order, dayId, getDisplayTime)
const times = order.map(keyOf).filter((m): m is number => m != null)
return times.some((m, i) => i > 0 && m < times[i - 1])
}
@@ -210,6 +210,28 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
// A day_number of 0 is as unusable as a missing one — both fall back to the row position.
const dayLabel = day?.title || t('planner.dayN', { n: day ? day.day_number || dayIndex + 1 : '?' })
const heading = dayHeadingParts(dayLabel, formattedDate, dateFirst)
// The input takes the size of the line it edits: the heading, or the line under the date.
const titleInput = (lead: boolean) => (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
placeholder={t('planner.dayN', { n: dayIndex + 1 })}
className={`w-full border-b-[1.5px] border-[color:var(--m-ink)] bg-transparent p-0 font-[inherit] outline-none ${lead ? 'text-[1.0625rem] font-bold' : ''}`}
/>
)
const renamePencil = canEditDays && day && (
<button
type="button"
onClick={() => startRename(day.title || '')}
aria-label={t('mobileTrip.renameDay')}
className="flex flex-none p-[3px] text-m-faint"
>
<Pencil size={12} strokeWidth={1.8} />
</button>
)
const WeatherIcon = weatherIconFor(weather?.main)
const stayBadge = (acc: (typeof dayAccommodations)[number]) => {
@@ -224,34 +246,25 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
{day && (
<>
<div className="flex-none px-[18px] pt-4">
{/* The pencil and the input go with the name: under the date when it leads (#2921). */}
<TileHeader
icon={<CalendarDays size={19} strokeWidth={1.8} />}
title={editingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
placeholder={t('planner.dayN', { n: dayIndex + 1 })}
className="w-full border-b-[1.5px] border-[color:var(--m-ink)] bg-transparent p-0 font-[inherit] text-[1.0625rem] font-bold outline-none"
/>
title={editingTitle && heading.nameFirst ? (
titleInput(true)
) : (
<>
<span className="truncate">{heading.primary}</span>
{canEditDays && (
<button
type="button"
onClick={() => startRename(day.title || '')}
aria-label={t('mobileTrip.renameDay')}
className="flex flex-none p-[3px] text-m-faint"
>
<Pencil size={12} strokeWidth={1.8} />
</button>
)}
{heading.nameFirst && renamePencil}
</>
)}
sub={heading.secondary}
sub={!heading.nameFirst && heading.secondary ? (
editingTitle ? titleInput(false) : (
<span className="flex min-w-0 items-center gap-1">
<span className="truncate">{heading.secondary}</span>
{renamePencil}
</span>
)
) : heading.secondary}
onClose={shell.closeSheet}
closeLabel={t('common.close')}
/>
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
import { useTripStore } from '../../../../store/tripStore'
import { useTranslation } from '../../../../i18n'
import { useTripExport } from '../../../../components/Planner/useTripExport'
import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell'
import type { LucideIcon } from 'lucide-react'
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
const open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false)
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
useExportDeepLink(() => shell.openSheet('export'))
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
// applies the day plan's filter itself, so both shells print the same. The PDF stays
// open here; a calendar or GPX file closes the sheet.
+8
View File
@@ -1837,4 +1837,12 @@ describe('AdminPage', () => {
await screen.findByRole('button', { name: /^users$/i });
expect(screen.queryByRole('button', { name: /^storage$/i })).not.toBeInTheDocument();
});
it('FE-PAGE-ADMIN-STOR-003: managed mode hides the Plugins tab, and ?tab=plugins falls back to Users', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
render(<AdminPage />, { initialEntries: ['/admin?tab=plugins'] });
await screen.findByRole('button', { name: /^users$/i });
expect(screen.queryByRole('button', { name: /^plugins$/i })).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('heading', { name: /plugins/i })).not.toBeInTheDocument());
});
});
+3 -1
View File
@@ -60,7 +60,9 @@ function AdminPageDesktop(): React.ReactElement {
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
// Plugins run third-party code that can send data anywhere; a hosted instance
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
// Storage backends and their credentials are hoster-level configuration —
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
+3 -5
View File
@@ -4,10 +4,9 @@ import { render, screen, waitFor, cleanup, within } from '../../tests/helpers/re
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 { resetAllStores } from '../../tests/helpers/store';
import { seedJourneyOwner } from '../../tests/helpers/journeyDetail';
import { buildUser } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { useJourneyStore } from '../store/journeyStore';
import JourneyDetailPage from './JourneyDetailPage';
@@ -238,8 +237,7 @@ beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
useJourneyStore.setState(useJourneyStore.getState(), true);
seedStore(useAuthStore, { isAuthenticated: true, user: ownerUser });
seedStore(usePermissionsStore, { level: 'owner' } as any);
seedJourneyOwner(ownerUser);
setupDefaultHandlers();
});
+2 -2
View File
@@ -38,7 +38,7 @@ function SettingsPageDesktop(): React.ReactElement {
// Always there: API keys sit in this tab and need no addon. The addon backed
// sections inside it gate themselves.
{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug },
...(hasPlugins
...(hasPlugins && !managed
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Blocks }]
: []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
@@ -87,7 +87,7 @@ function SettingsPageDesktop(): React.ReactElement {
{activeTab === 'map' && <MapSettingsTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'integrations' && <IntegrationsTab />}
{activeTab === 'plugins' && hasPlugins && <PluginSettingsTab />}
{activeTab === 'plugins' && hasPlugins && !managed && <PluginSettingsTab />}
{activeTab === 'offline' && <OfflineTab />}
{activeTab === 'account' && <AccountTab />}
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
@@ -78,6 +78,21 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</EditorField>
)
const tripRemindersCard = (
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
}
/>
)
return (<>
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
cards are a title with a switch on the far right, so on a wide screen the
@@ -168,19 +183,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
action={<LockedSwitch />}
/>
{/* Trip Reminders Toggle */}
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
}
/>
{!managed && tripRemindersCard}
</div>
<div className="min-w-0">
@@ -284,6 +287,9 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
over, so it belongs under the targets it routes to rather than across the
full width below both columns. */}
<AdminNotificationsPanel t={t} toast={toast} />
{/* Without the admin alert cards the right column is short, so the
reminder switch moves over on a managed install. */}
{managed && tripRemindersCard}
</div>
</div>
@@ -320,24 +320,24 @@ describe('AdminSettingsTab', () => {
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022e: a managed install hides the keys but keeps the provider choice', () => {
// The operator owns the credentials; which of them answers place search is
// still the admin's call (server managed.ts lists places_provider as theirs).
it('FE-ADMSET-022e: a managed install hides place search, routing and the keys', () => {
// All of it is the operator's there (server managed.ts locks places_provider);
// Share place details has a tab of its own on AdminPage.
renderTab({ managed: true, placesProvider: 'auto' });
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
expect(within(card(SERVICES)).getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: SERVICES })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
});
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
// The key fields are not on the page here, and an operator key set through
// the environment would not show up in them anyway. app-config knows.
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
// With place search and the keys gone the right column would stand empty.
renderTab({ managed: true });
expect(
within(card(SERVICES)).queryByText(/falls back to OpenStreetMap/i),
).not.toBeInTheDocument();
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
expect(right).not.toBeNull();
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
+35 -19
View File
@@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions'
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types'
@@ -140,6 +141,29 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
const passwordLoginLocked = !passwordLogin && !oidcLogin
const oidcLoginLocked = !passwordLogin && oidcLogin
// On a managed install the right column loses place search, routing and the
// keys to the operator, so file types move over and the columns stay even.
const fileTypesCard = (
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
<input
type="text"
aria-label={t('admin.fileTypes')}
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className={`${INPUT} font-geist`}
/>
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
</div>
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={saveFileTypes}
/>
</SettingsCard>
)
return (
<div>
{/* Two columns from xl up. Nearly every card here is "label left,
@@ -351,33 +375,24 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingsCard>
)}
{/* Allowed File Types */}
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
<input
type="text"
aria-label={t('admin.fileTypes')}
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className={`${INPUT} font-geist`}
/>
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
</div>
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={saveFileTypes}
/>
</SettingsCard>
{!managed && fileTypesCard}
</div>
<div className="min-w-0">
{managed && fileTypesCard}
{/* Inside the place search card elsewhere; that card is the operator's here,
sharing place details is still the admin's decision. */}
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
{/* Place search and routing: TREK's own services first, then which provider
answers search and transit, and the search log. Apart from the keys, so a
keyless install reads its whole setup in one card. Everything here saves
as it is changed. */}
{/* On a managed install place search and routing are the operator's: the
services, their allowance and the providers behind them come with the
instance. Share place details stays the admin's call and has a tab of
its own there (AdminPage). */}
{!managed && (
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
<TrekApiCard t={t} managed={managed} />
{!managed && (<>
@@ -486,6 +501,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingRows>
</SettingsCard>
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there;
+1 -1
View File
@@ -33,7 +33,7 @@ const ADMIN_TAB_IDS = [
* hosted instance. Checked in an effect rather than in the initial state
* because `managed` arrives with /app-config, after the first render.
*/
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
const MANAGED_HIDDEN = ['storage', 'github', 'backup', 'plugins']
/** How often the open users list asks again who is online and active. */
const USERS_REFRESH_MS = 30_000
+14 -1
View File
@@ -1,5 +1,5 @@
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourActivityAssignRequest, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourSkiClassificationRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
import { toursApi } from '../api/client'
import { placesApi, toursApi } from '../api/client'
import { tourGpxApi } from '../api/tourGpx'
import { recordedTrackApi } from '../api/recordedTrack'
import { tourActivitiesApi } from '../api/tourActivities'
@@ -499,6 +499,19 @@ export const tourRepo = {
return result
},
/**
* The single points of a file (GPX waypoints, KML pins) added as places of
* the trip through the places import, which reads nothing else of it here
* (#2969). Online only, like the Tours import beside it. Answers how many
* places came in.
*/
async importFilePoints(tripId: number | string, file: File): Promise<number> {
const result = file.name.toLowerCase().endsWith('.gpx')
? await placesApi.importGpx(tripId, file, { waypoints: true, routes: false, tracks: false })
: await placesApi.importMapFile(tripId, file, { points: true, paths: false })
return result.count ?? 0
},
/** Reads a GPX, KML or KMZ file for its preview; online only, like the import, and nothing is cached. */
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
return tourGpxApi.preview(tripId, file)
+20 -5
View File
@@ -1,6 +1,6 @@
// FE-UTIL-DAYLABEL-001 to FE-UTIL-DAYLABEL-003
import { describe, it, expect } from 'vitest'
import { dayHeadingParts, dayLabel } from './dayLabel'
import { dayHeadingParts, dayLabel, headingFit } from './dayLabel'
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`
@@ -23,15 +23,30 @@ describe('dayLabel', () => {
describe('dayHeadingParts', () => {
it('FE-UTIL-DAYLABEL-004: the name leads by default and the date follows', () => {
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', false)).toEqual({ primary: 'Day 2', secondary: 'Mon, Aug 17' })
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', false)).toEqual({ primary: 'Day 2', secondary: 'Mon, Aug 17', nameFirst: true })
})
it('FE-UTIL-DAYLABEL-005: planning by the calendar puts the date first', () => {
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', true)).toEqual({ primary: 'Mon, Aug 17', secondary: 'Day 2' })
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', true)).toEqual({ primary: 'Mon, Aug 17', secondary: 'Day 2', nameFirst: false })
})
it('FE-UTIL-DAYLABEL-006: a day without a date keeps its name in front either way', () => {
expect(dayHeadingParts('Day 2', null, true)).toEqual({ primary: 'Day 2', secondary: null })
expect(dayHeadingParts('Day 2', '', false)).toEqual({ primary: 'Day 2', secondary: null })
expect(dayHeadingParts('Day 2', null, true)).toEqual({ primary: 'Day 2', secondary: null, nameFirst: true })
expect(dayHeadingParts('Day 2', '', false)).toEqual({ primary: 'Day 2', secondary: null, nameFirst: true })
})
})
describe('headingFit', () => {
it('FE-UTIL-DAYLABEL-007: with the date first the name behind it gives way and the date stays whole (#2920)', () => {
const fit = headingFit(dayHeadingParts('Arrival, East Coast & Optional Vegueta Evening', 'Thursday, November 12', true))
expect(fit.primary).toContain('flex-none')
expect(fit.primary).not.toContain('truncate')
expect(fit.secondary).toContain('truncate')
})
it('FE-UTIL-DAYLABEL-008: with the name first the name gives way, as before', () => {
const fit = headingFit({ nameFirst: true })
expect(fit.primary).toContain('truncate')
expect(fit.secondary).toContain('flex-none')
})
})
+14 -3
View File
@@ -33,8 +33,19 @@ export function dayDate(iso: string, locale: string): string | null {
* A day heading's two parts in reading order. The day's name ("Day 3" or its own
* title) leads, unless the traveller plans by the calendar and asked for the date
* first (#1953). A day without a date always leads with its name.
*
* `nameFirst` says which of the two is the name: the rename pencil and the title
* input belong with the name wherever it stands (#2921), and it is the name that
* gives way when the line is short, never the date (#2920).
*/
export function dayHeadingParts(name: string, date: string | null | undefined, dateFirst: boolean): { primary: string; secondary: string | null } {
if (dateFirst && date) return { primary: date, secondary: name }
return { primary: name, secondary: date || null }
export function dayHeadingParts(name: string, date: string | null | undefined, dateFirst: boolean): { primary: string; secondary: string | null; nameFirst: boolean } {
if (dateFirst && date) return { primary: date, secondary: name, nameFirst: false }
return { primary: name, secondary: date || null, nameFirst: true }
}
/** On one line the name gives way when it runs long, the date stays whole: with the date first a long title squeezed it to "Th…" (#2920). */
export function headingFit({ nameFirst }: { nameFirst: boolean }): { primary: string; secondary: string } {
const gives = 'min-w-0 truncate'
const keeps = 'flex-none whitespace-nowrap'
return nameFirst ? { primary: gives, secondary: keeps } : { primary: keeps, secondary: gives }
}
+100 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { planMergedOrder, withTransportPositions } from './mergedOrder'
describe('parseTimeToMinutes', () => {
@@ -648,3 +648,102 @@ describe('seatTimedDrop', () => {
})
})
// The report behind #2929: Auckland to Los Angeles leaves at 20:20 and lands at 12:25 the
// same date, local time at each end; the flight on to Seattle leaves Los Angeles at 17:00.
// By the clock alone 17:00 comes first, so the second flight was drawn above the first.
describe('getMergedItems: bookings in different time zones (#2929)', () => {
const DAY = 5
const days = [{ id: DAY, day_number: 1, date: '2026-11-20' }]
const flight = (id: number, time: string, end: string, from: string | null, to: string | null) => ({
id, type: 'flight', day_id: DAY, end_day_id: DAY,
reservation_time: `2026-11-20T${time}`, reservation_end_time: `2026-11-20T${end}`,
metadata: JSON.stringify({ ...(from ? { departure_timezone: from } : {}), ...(to ? { arrival_timezone: to } : {}) }),
})
const toLA = flight(1, '20:20', '12:25', 'Pacific/Auckland', 'America/Los_Angeles')
const toSeattle = flight(2, '17:00', '19:45', 'America/Los_Angeles', 'America/Los_Angeles')
const ids = (items: MergedItem[]) => items.map(i => i.data.id)
it('puts the Auckland flight ahead of the Los Angeles one it connects to', () => {
for (const dayTransports of [[toLA, toSeattle], [toSeattle, toLA]]) {
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }))).toEqual([1, 2])
}
})
it('keeps the stops of the day where their clock puts them', () => {
const dayAssignments = [
{ id: 10, order_index: 0, place: { place_time: '09:00' } },
{ id: 11, order_index: 1, place: { place_time: '18:00' } },
]
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
// The flights swap the slots they hold between them (17:00 and 20:20); the stops stay put.
expect(result.map(i => `${i.type}:${i.data.id}`)).toEqual(['place:10', 'transport:1', 'place:11', 'transport:2'])
})
it('reads the zone from the endpoints when the metadata names none', () => {
const endpointsOf = (fromTz: string, toTz: string) => [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 0, lng: 0, timezone: fromTz, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 0, lng: 0, timezone: toTz, local_time: null, local_date: null },
]
const a = { ...flight(1, '20:20', '12:25', null, null), endpoints: endpointsOf('Pacific/Auckland', 'America/Los_Angeles') }
const b = { ...flight(2, '17:00', '19:45', null, null), endpoints: endpointsOf('America/Los_Angeles', 'America/Los_Angeles') }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [b, a], dayId: DAY }))).toEqual([1, 2])
})
it('orders the legs of one multi-leg booking by when they leave', () => {
const booking = {
id: 3, type: 'flight', day_id: DAY, end_day_id: DAY,
reservation_time: '2026-11-20T20:20', reservation_end_time: '2026-11-20T19:45',
endpoints: [
{ role: 'from', sequence: 0, name: 'Auckland', code: 'AKL', lat: -37.0, lng: 174.8, timezone: 'Pacific/Auckland', local_time: null, local_date: null },
{ role: 'stop', sequence: 1, name: 'Los Angeles', code: 'LAX', lat: 33.9, lng: -118.4, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
{ role: 'to', sequence: 2, name: 'Seattle', code: 'SEA', lat: 47.4, lng: -122.3, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
],
metadata: JSON.stringify({ legs: [
{ from: 'AKL', to: 'LAX', dep_day_id: DAY, dep_time: '20:20', arr_day_id: DAY, arr_time: '12:25' },
{ from: 'LAX', to: 'SEA', dep_day_id: DAY, dep_time: '17:00', arr_day_id: DAY, arr_time: '19:45' },
] }),
}
const dayTransports = getTransportForDay({ reservations: [booking], dayId: DAY, dayAssignmentIds: [], days })
const legs = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }).map(i => i.data.__leg.from)
expect(legs).toEqual(['AKL', 'LAX'])
})
it('orders by the clock when a booking names no zone', () => {
const noZone = flight(1, '20:20', '12:25', null, null)
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [noZone, toSeattle], dayId: DAY }))).toEqual([2, 1])
})
it('orders by the clock when the time carries no date', () => {
const a = { ...toLA, reservation_time: '20:20' }
const b = { ...toSeattle, reservation_time: '17:00' }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [a, b], dayId: DAY }))).toEqual([2, 1])
})
it('reads the arrival zone on the day an overnight flight lands', () => {
const NEXT = 6
// Leaves Auckland the evening before and lands in Los Angeles at 12:25 local (20:25 UTC).
// A flight out of New York at 14:00 local (19:00 UTC) left before it, though its clock
// reads later, and 12:25 read in Auckland's zone would put the landing a day early.
const overnight = {
id: 1, type: 'flight', day_id: DAY, end_day_id: NEXT,
reservation_time: '2026-11-19T20:20', reservation_end_time: '2026-11-21T12:25',
metadata: JSON.stringify({ departure_timezone: 'Pacific/Auckland', arrival_timezone: 'America/Los_Angeles' }),
}
const hop = { id: 2, type: 'flight', day_id: NEXT, end_day_id: NEXT, reservation_time: '2026-11-21T14:00',
metadata: JSON.stringify({ departure_timezone: 'America/New_York' }) }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [overnight, hop], dayId: NEXT }))).toEqual([2, 1])
})
it('seats a dragged flight where its zone puts it', () => {
const order = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toLA, toSeattle], dayId: DAY })
expect(ids(seatTimedDrop([order[1], order[0]], 0, DAY))).toEqual([1, 2])
})
it('gives the zoned flights the key they are drawn by, so the drawn order reads as in order', () => {
const items = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
const keyOf = chronoKeyOf(items, DAY, getDisplayTimeForDay)
// Drawn Auckland first: it takes the 17:00 slot, the Seattle flight the 20:20 one.
expect(items.map(keyOf)).toEqual([17 * 60, 20 * 60 + 20])
})
})

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