Compare commits

...

65 Commits

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

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

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

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

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

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

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

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

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

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

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

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

For every install:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude Sonnet 5.5 at times ends a turn on suggest_follow_ups alone, without
writing its answer, and the turn failed as empty_answer. That step now goes
back to the model whole, its follow-ups answered that the user saw nothing
yet, and their chips come under the answer it then writes. The prompts no
longer say the call goes alone in its step.
2026-11-01 10:18:27 +01:00
Maurice cafc8dd46e fix: link dataforest cloud with /en again
The 5.1.0 popup, the sponsor panel in Settings > About and the README had
switched to https://cloud.dataforest.net/?mtm_campaign=trek. The link stays
https://cloud.dataforest.net/en?mtm_campaign=trek everywhere.
2026-10-31 12:26:36 +01:00
547 changed files with 15390 additions and 1891 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",
+4 -4
View File
@@ -15,7 +15,7 @@
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+5 -5
View File
@@ -742,11 +742,8 @@
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Map: useMapSettingsForm",
@@ -983,8 +980,11 @@
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
+1 -1
View File
@@ -34,7 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/PDF/TripPDF.tsx": 8,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
+4 -5
View File
@@ -2,23 +2,22 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/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,
+2 -2
View File
@@ -71,7 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/PDF/TripPDF.tsx": 53,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
+22
View File
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
// A page opened while the server restarts got a 502 here and kept every
// default for the session, which brought back the operator's settings.
let calls = 0
server.use(
http.get('/api/auth/app-config', () => {
calls += 1
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
})
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
expect(calls).toBe(2)
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
+25 -3
View File
@@ -323,7 +323,21 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
// Asked again until it answers: a page opened while the server restarts got a
// 502 here and kept the defaults for the whole session, which on a managed
// install put back every setting the operator owns.
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
if (cancelled) throw err
return new Promise<AppConfigAnswer>((resolve, reject) => {
retryTimer = setTimeout(() => {
fetchAppConfig(attempt + 1).then(resolve, reject)
}, Math.min(30_000, 1_000 * 2 ** attempt))
})
})
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -347,6 +361,10 @@ export default function App() {
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
return () => {
cancelled = true
clearTimeout(retryTimer)
}
}, [])
const { settings } = useSettingsStore()
@@ -402,6 +420,10 @@ export default function App() {
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
// The Help & Docs page is the wiki, written for somebody who runs TREK
// themselves: keys, backups, updates, the server behind it. A managed install
// does not offer it; the in-app help center stays.
const managed = useAuthStore(s => s.managed)
return (
<TranslationProvider>
@@ -456,7 +478,7 @@ export default function App() {
path="/help"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -464,7 +486,7 @@ export default function App() {
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
+15
View File
@@ -0,0 +1,15 @@
import type { StorageReclaimConfirmRequest, StorageReclaimState } from '@trek/shared';
import { apiClient } from './client';
/** Admin > Storage, unreferenced files: the nightly deletion switch, a check on demand, and the large-share confirmation. */
export const storageReclaimApi = {
setDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
/** Starts a report-only check in the background (202); its report arrives through the admin state. */
check: (): Promise<{ started: true }> => apiClient.post('/admin/storage/reclaim/run').then((r) => r.data),
/** `unreferenced` is the count the admin was shown: the server refuses when its own count has moved on. */
confirm: (request: StorageReclaimConfirmRequest): Promise<StorageReclaimState> =>
apiClient
.post('/admin/storage/reclaim/confirm', request satisfies StorageReclaimConfirmRequest)
.then((r) => r.data),
};
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
seedStore(useAuthStore, { managed: true });
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
render(<AddonManager />);
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
await unfoldShelves();
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
+13 -8
View File
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...(wandererOffered
? [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
]
: []),
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
const date = (iso: string) => formatDate(new Date(iso))
return (
<div>
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
import { deriveCaps } from './pluginCaps'
import PluginPoiCategoryList from './PluginPoiCategoryList'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { detail, failed } = usePluginDetail(item.id)
const { formatDate } = useFormatDate()
const manifest = detail?.manifest ?? null
const permissions = manifest?.permissions ?? []
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}>
<VersionLine v={v} locale={locale} />
<VersionLine v={v} />
</DialogListItem>
))}
</DialogList>
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<DialogShell
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
{current ? (
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
) : v.compatible ? (
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
});
});
describe('DefaultUserSettingsTab date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await pickFromSelect(user, '2026-12-31', '31.12.2026');
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
});
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
});
});
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import type { MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onChange={saveMapProvider}
options={[
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]}
/>
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -1,4 +1,4 @@
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
import userEvent from '@testing-library/user-event';
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
import { http, HttpResponse } from 'msw';
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
vi.useRealTimers();
}
});
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
const asked = vi.fn();
server.use(
http.get('/api/admin/places-contribute', () => {
asked();
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
})
);
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
await waitFor(() => expect(asked).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
render(<PlaceContribPanel hideWhenSwitchedOff />);
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
});
});
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
* way to take everything back. Turning it on for the first time opens the same
* prompt an admin gets on the first visit, so an agreement always comes from it.
*/
export default function PlaceContribPanel() {
/**
* Standing alone (managed settings), a card that can only say "not available"
* because the operator switched sharing off is a dead end.
*/
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
return alone && state.ineligibleReason === 'env_off';
}
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
const { t } = useTranslation();
const toast = useToast();
const contrib = usePlaceContrib();
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
if (!state) return null;
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
const status = contribStatus(state);
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
// The prompt opens by itself once per visit while the server asks for it.
@@ -175,7 +175,7 @@ export default function PlaceContribPrompt({
))}
</ul>
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
{t('admin.contribute.prompt.how')}
{t('admin.contribute.prompt.how')} {t('admin.contribute.prompt.past')}
</p>
</section>
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
@@ -36,6 +37,9 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
// wanderer sends a user's searches to a server of their choosing, which a
// managed install's terms do not cover; the operator decides, not the admin.
const wandererOffered = !useAuthStore((s) => s.managed);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
@@ -160,6 +164,7 @@ export function useAddonManager() {
providerOptions,
documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
};
@@ -0,0 +1,25 @@
import { ShieldCheck } from 'lucide-react';
import { useFormatDate } from '../../../hooks/useDateFormat';
import { fs } from '../../shared/DialogShell';
import type { VersionInfo } from './pluginModel';
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate();
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
style={fs(12.5)}
>
v{v.version}
</span>
{v.publishedAt && (
<span className="text-content-faint" style={fs(11.5)}>
{formatDate(new Date(v.publishedAt))}
</span>
)}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
);
}
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageCategory, type StorageConfig } from '@trek/shared';
import { MASKED_SETTING_VALUE, STORAGE_RECLAIM_CATEGORIES, type StorageAdminState, type StorageCategory, type StorageConfig, type StorageReclaimReport } from '@trek/shared';
import { server } from '../../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { ToastContainer } from '../../shared/Toast';
@@ -11,6 +11,16 @@ const S3_MASKED = {
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
};
/** A last check with three covers a week old and two younger ones; every other category never reclaimed. */
function reclaimReport(): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: 'not_reclaimed' as const }]),
) as StorageReclaimReport['categories'];
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: null };
categories.places = { objects: 10, unreferenced: { objects: 3, bytes: 300 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: true, skipped: null };
return { startedAt: Date.now() - 60_000, finishedAt: Date.now() - 30_000, mode: 'report', stopped: null, categories };
}
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
return {
backends: [
@@ -35,6 +45,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
},
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -1217,4 +1228,55 @@ describe('AdminStoragePanel', () => {
expect(within(row).getByText(/Sync failed: disk full/)).toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Sync now' })).toBeInTheDocument();
});
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch, the check and a confirmation go to the server', async () => {
const lastRun = reclaimReport();
let put: unknown = null;
let runs = 0;
let confirm: unknown = null;
server.use(
http.put('/api/admin/storage/reclaim', async ({ request }) => {
put = await request.json();
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
http.post('/api/admin/storage/reclaim/run', () => {
runs += 1;
return HttpResponse.json({ started: true }, { status: 202 });
}),
http.post('/api/admin/storage/reclaim/confirm', async ({ request }) => {
confirm = await request.json();
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun, lastDeleteRun: null } }));
// Once a check started, the state the panel polls says it runs.
server.use(
http.get('/api/admin/storage', () =>
HttpResponse.json(baseState({ reclaim: { deleteEnabled: true, running: runs > 0, lastRun, lastDeleteRun: null } })),
),
);
expect(screen.getByText('Unreferenced files')).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-covers')).getByText(/Can be freed/)).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-places')).getByText(/More than a fifth/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete unreferenced files' }));
await waitFor(() => expect(put).toEqual({ deleteEnabled: true }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete unreferenced files' })).toHaveAttribute('aria-pressed', 'true'));
fireEvent.click(within(screen.getByTestId('storage-reclaim-places')).getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(confirm).toEqual({ category: 'places', unreferenced: 3 }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(runs).toBe(1));
// The check runs in the background: the panel shows it running until the state says otherwise.
await waitFor(() => expect(screen.getByText('Checking…')).toBeInTheDocument());
});
it('FE-ADMIN-STOR-051b: a refused check names the reason in the operator\'s language', async () => {
server.use(
http.post('/api/admin/storage/reclaim/run', () =>
HttpResponse.json({ error: 'busy', code: 'reclaim_busy' }, { status: 409 }),
),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null } }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(screen.getByText('Another storage task is running. Try again in a moment.')).toBeInTheDocument());
});
});
@@ -38,6 +38,7 @@ import {
StatusPill,
} from '../../Settings/settingsKit'
import BackendForm from './BackendForm'
import StorageReclaimSection from './StorageReclaimSection'
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
@@ -96,6 +97,7 @@ export default function AdminStoragePanel(): React.ReactElement {
const {
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
// Open/closed only: the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh rather than reading the
@@ -627,6 +629,18 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
)}
<div className="mt-5">
<StorageReclaimSection
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
</div>
{editing && (
<BackendForm
initial={editing.initial}
@@ -0,0 +1,167 @@
import { Eraser, Loader2, ScanSearch } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { formatBytes } from '../../../utils/formatBytes';
import { relativeTime } from '../../../utils/relativeTime';
import Section from '../../Settings/Section';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import type { ReclaimCount, ReclaimView } from './reclaimModel';
interface Props {
reclaim: ReclaimView;
/** A check was asked for or is running. */
checking: boolean;
/** The switch is on its way to the server. */
saving: boolean;
onCheck: () => void;
onDelete: (enabled: boolean) => void;
/** The category whose confirmation is on its way. */
confirming: ReclaimView['lines'][number]['category'] | null;
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
}
/**
* Unreferenced files on the desktop storage panel: what the last check found,
* the switch that lets the nightly run delete them, a check on demand and the
* confirmation a category with an unusually large share of unused files waits
* for. The phone shows the same through MStorageReclaimCard, off the same view model.
*/
export default function StorageReclaimSection({
reclaim,
checking,
saving,
onCheck,
onDelete,
confirming,
onConfirm,
}: Props): React.ReactElement {
const { t, locale } = useTranslation();
const count = (c: ReclaimCount) => t('storage.usage.line', { count: c.objects, size: formatBytes(c.bytes) });
const checked = reclaim.checkedAt !== null;
return (
<Section
title={t('storage.reclaim.title')}
icon={Eraser}
hint={t('storage.reclaim.hint')}
action={
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
onClick={onCheck}
disabled={checking}
>
{checking ? (
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
) : (
<ScanSearch size={14} strokeWidth={2.2} />
)}
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
</button>
}
>
<SettingRows>
<SettingRow
label={t('storage.reclaim.delete')}
hint={t('storage.reclaim.deleteHint')}
dimmed={saving}
control={
<ToggleSwitch
on={reclaim.deleteEnabled}
onToggle={() => onDelete(!reclaim.deleteEnabled)}
label={t('storage.reclaim.delete')}
disabled={saving}
/>
}
/>
<div className="px-3.5 py-3" data-testid="storage-reclaim-summary">
<p className="m-0 text-content-faint" style={fs(11.5)}>
{reclaim.checkedAt !== null
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
: t('storage.reclaim.never')}
</p>
{reclaim.stopped && (
<p role="note" className="m-0 mt-1.5 leading-snug text-warning" style={fs(11.5)}>
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
</p>
)}
{checked && (
<div className="mt-2.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
<Figure label={t('storage.reclaim.freeable')} value={count(reclaim.freeable)} />
<Figure label={t('storage.reclaim.waiting')} value={count(reclaim.waiting)} />
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.freed)} />
</div>
)}
{checked && !reclaim.stopped && reclaim.lines.length === 0 && (
<SettingsHint className="mt-2.5">{t('storage.reclaim.nothing')}</SettingsHint>
)}
{reclaim.lines.length > 0 && (
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
{reclaim.lines.map((line) => (
<li
key={line.category}
data-testid={`storage-reclaim-${line.category}`}
className="flex flex-col gap-1"
>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
{t(`storage.category.${line.category}`)}
</span>
{line.freeable.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.freeable')}: {count(line.freeable)}
</span>
)}
{line.waiting.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.waiting')}: {count(line.waiting)}
</span>
)}
{line.skipped && (
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
)}
</div>
{line.confirmationRequired && (
<div className="flex flex-wrap items-center gap-2.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5">
<p className="m-0 min-w-0 flex-1 basis-56 leading-snug text-warning" style={fs(11.5)}>
{t('storage.reclaim.confirmNeeded')}
</p>
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
disabled={confirming === line.category}
>
{confirming === line.category && (
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
)}
{t('storage.reclaim.confirm')}
</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
</SettingRows>
</Section>
);
}
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="min-w-0 rounded-[12px] bg-surface-secondary px-3 py-2">
<p className="m-0 text-content-faint" style={fs(11)}>
{label}
</p>
<p className="m-0 mt-0.5 font-geist font-semibold tabular-nums text-content" style={fs(12.5, 'body')}>
{value}
</p>
</div>
);
}
@@ -0,0 +1,140 @@
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
import { reclaimView } from './reclaimModel';
const none = { objects: 0, bytes: 0 };
function category(patch: Partial<StorageReclaimCategoryReport> = {}): StorageReclaimCategoryReport {
return {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
...patch,
};
}
function report(
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>,
mode: StorageReclaimReport['mode'] = 'report'
): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, category({ skipped: 'not_reclaimed' })])
) as StorageReclaimReport['categories'];
return { startedAt: 1_000, finishedAt: 2_000, mode, stopped: null, categories: { ...categories, ...patch } };
}
describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off, not running and never checked', () => {
expect(reclaimView(null)).toEqual({
deleteEnabled: false,
running: false,
checkedAt: null,
stopped: null,
freeable: none,
waiting: none,
freed: none,
freedAt: null,
lines: [],
});
});
it('FE-ADMIN-STOR-RECLAIM-002: freeable is what is a week old, waiting what is younger, summed over categories', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({
covers: category({
objects: 40,
unreferenced: { objects: 5, bytes: 500 },
eligible: { objects: 3, bytes: 300 },
}),
places: category({ objects: 10, unreferenced: { objects: 1, bytes: 100 } }),
}),
});
expect(view.deleteEnabled).toBe(true);
expect(view.checkedAt).toBe(2_000);
expect(view.freeable).toEqual({ objects: 3, bytes: 300 });
expect(view.waiting).toEqual({ objects: 3, bytes: 300 });
expect(view.lines.map((line) => line.category)).toEqual(['covers', 'places']);
});
it('FE-ADMIN-STOR-RECLAIM-003: freed comes from the last run with deletion on, which a check on demand does not replace', () => {
const deleting = report(
{
files: category({
unreferenced: { objects: 2, bytes: 20 },
eligible: { objects: 2, bytes: 20 },
deleted: { objects: 2, bytes: 20 },
}),
},
'delete'
);
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: { ...deleting, finishedAt: 900 },
lastRun: report({}),
});
expect(view.freed).toEqual({ objects: 2, bytes: 20 });
expect(view.freedAt).toBe(900);
expect(view.freeable).toEqual(none);
expect(view.lines).toEqual([]);
});
it('FE-ADMIN-STOR-RECLAIM-004: a skipped category shows its reason and counts toward nothing, one never reclaimed shows nothing', () => {
const view = reclaimView({
deleteEnabled: false,
running: false,
lastDeleteRun: null,
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
});
expect(view.lines).toEqual([
{
category: 'avatars',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 9,
skipped: 'foreign_install',
},
]);
expect(view.waiting).toEqual(none);
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
});
it('FE-ADMIN-STOR-RECLAIM-005: a pass that stopped says why, and a category it stopped in keeps its partial figures', () => {
const view = reclaimView({
deleteEnabled: false,
running: false,
lastDeleteRun: null,
lastRun: {
...report({ covers: category({ skipped: 'interrupted', unreferenced: { objects: 4, bytes: 40 } }) }),
stopped: 'failed',
},
});
expect(view.stopped).toBe('failed');
expect(view.waiting).toEqual({ objects: 4, bytes: 40 });
expect(view.lines[0]).toMatchObject({ category: 'covers', skipped: 'interrupted' });
});
it('FE-ADMIN-STOR-RECLAIM-006: a category waiting for a confirmation shows even with nothing old enough yet', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
});
expect(view.lines).toEqual([
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, unreferenced: 0, skipped: null },
]);
});
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
});
});
@@ -0,0 +1,123 @@
import {
STORAGE_RECLAIM_CATEGORIES,
type StorageReclaimCategoryReport,
type StorageReclaimReport,
type StorageReclaimState,
} from '@trek/shared';
/** A number of files and their size, as the report counts them. */
export interface ReclaimCount {
objects: number;
bytes: number;
}
/** What one category of the last check has to say, when it has anything to say. */
interface ReclaimCategoryLine {
category: (typeof STORAGE_RECLAIM_CATEGORIES)[number];
/** Unused for a week or more: what the nightly run deletes once deletion is on. */
freeable: ReclaimCount;
/** Unused for less than a week: these get their week first. */
waiting: ReclaimCount;
/**
* An unusually large share of the category looks unused: nothing in it is
* deleted until an admin confirms that this is expected (a cleaned-up
* backlog, say, rather than a second installation in the same bucket).
*/
confirmationRequired: boolean;
/** Everything the check found unused in the category, the count a confirmation is given for. */
unreferenced: number;
/** Why the category was left alone, or stopped part way; null when it was checked through. */
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
}
/** The admin's view of the reclaim pass: the switch and what the last check found. */
export interface ReclaimView {
deleteEnabled: boolean;
/** A check, nightly or on demand, is running now. */
running: boolean;
/** When the last check finished, ms; null before the first one. */
checkedAt: number | null;
/** Why the last check stopped before the end; null when it ran through. */
stopped: StorageReclaimReport['stopped'];
freeable: ReclaimCount;
waiting: ReclaimCount;
/** What the last run with deletion on removed: a check on demand only reports, so it never resets this. */
freed: ReclaimCount;
/** When that run finished, ms; null when nothing ever ran with deletion on. */
freedAt: number | null;
/** Only the categories with something to free, something waiting, something to confirm, or a reason they were skipped. */
lines: ReclaimCategoryLine[];
}
const NONE: ReclaimCount = { objects: 0, bytes: 0 };
function add(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
return { objects: a.objects + b.objects, bytes: a.bytes + b.bytes };
}
function minus(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
return { objects: Math.max(0, a.objects - b.objects), bytes: Math.max(0, a.bytes - b.bytes) };
}
function freedBy(run: StorageReclaimReport | null): ReclaimCount {
if (!run) return NONE;
return STORAGE_RECLAIM_CATEGORIES.reduce(
(sum, category) => add(sum, run.categories[category]?.deleted ?? NONE),
NONE
);
}
/**
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
/**
* One category of the last check as a line, or null when it has nothing to
* say. A category the pass stopped in still counts what it found; one it
* never judged counts nothing.
*/
function lineOf(
category: ReclaimCategoryLine['category'],
report: StorageReclaimCategoryReport | undefined
): ReclaimCategoryLine | null {
if (!report || report.skipped === 'not_reclaimed') return null;
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
// What was deleted on this run no longer waits to be freed.
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
const line = {
category,
freeable,
waiting,
confirmationRequired: report.confirmationRequired,
unreferenced: report.unreferenced.objects,
skipped: report.skipped,
};
const quiet = freeable.objects === 0 && waiting.objects === 0 && !line.confirmationRequired && line.skipped === null;
return quiet ? null : line;
}
/**
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
const run = state?.lastRun ?? null;
const lines = STORAGE_RECLAIM_CATEGORIES.flatMap((category) => lineOf(category, run?.categories[category]) ?? []);
const freeable = lines.reduce((sum, line) => add(sum, line.freeable), NONE);
const waiting = lines.reduce((sum, line) => add(sum, line.waiting), NONE);
const deleteRun = state?.lastDeleteRun ?? null;
return {
deleteEnabled: state?.deleteEnabled ?? false,
running: state?.running ?? false,
checkedAt: run?.finishedAt ?? null,
stopped: run?.stopped ?? null,
freeable,
waiting,
freed: freedBy(deleteRun),
freedAt: deleteRun?.finishedAt ?? null,
lines,
};
}
@@ -53,6 +53,7 @@ const STATE: StorageAdminState = {
},
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -14,6 +14,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -1,11 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
import type {
StorageAdminState,
StorageBackend,
StorageConfig,
StorageConfigPut,
StorageReclaimConfirmRequest,
StorageTestResponse,
} from '@trek/shared'
import { adminApi } from '../../../api/client'
import { storageReclaimApi } from '../../../api/storageReclaim'
import { getApiErrorMessage, type ApiError } from '../../../types'
import { settingsDocumentOf } from './storageModel'
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
export type ReclaimFailure = { code: string } | { message: string }
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
? { code: code.code }
: { message: getApiErrorMessage(err, fallback) }
}
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
@@ -58,6 +76,12 @@ export interface StorageAdmin {
startMigration: (category: string, to: string) => Promise<string | null>
cancelMigration: (category: string) => Promise<string | null>
refreshStats: () => Promise<string | null>
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
checkReclaim: () => Promise<ReclaimFailure | null>
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
confirmReclaim: (request: StorageReclaimConfirmRequest) => Promise<ReclaimFailure | null>
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
refreshState: () => Promise<void>
/**
@@ -271,7 +295,8 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
useEffect(() => {
if (
!state?.backfills.some((b) => b.status === 'running') &&
!state?.migrations.some((m) => m.status === 'running')
!state?.migrations.some((m) => m.status === 'running') &&
!state?.reclaim?.running
)
return
const iv = setInterval(() => {
@@ -356,6 +381,71 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
}
}, [genericError])
/**
* Writes the reclaim part of the state. It counts as a deliberate state
* application, so a poll GET that was in flight before it is dropped instead
* of putting the switch back where it was.
*/
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
const prev = stateRef.current
if (!prev) return
stateSeq.current += 1
const next = { ...prev, reclaim }
stateRef.current = next
setState(next)
}, [])
const setReclaimDelete = useCallback(
async (enabled: boolean): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.setDelete(enabled))
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
},
[genericError, setReclaim],
)
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
try {
await storageReclaimApi.check()
// Running from here on: the poll above picks the report up once the pass is done.
const current = stateRef.current?.reclaim
setReclaim({
deleteEnabled: current?.deleteEnabled ?? false,
running: true,
lastRun: current?.lastRun ?? null,
lastDeleteRun: current?.lastDeleteRun ?? null,
})
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
}, [genericError, setReclaim])
const confirmReclaim = useCallback(
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.confirm(request))
return null
} catch (err: unknown) {
const failure = reclaimFailureOf(err, genericError)
// The count moved on since it was shown: show the current one before the admin confirms again.
if ('code' in failure && failure.code === 'reclaim_confirm_stale') {
try {
await refreshState()
} catch (reloadErr: unknown) {
// Without the new figure there is nothing to confirm against: say why instead.
return reclaimFailureOf(reloadErr, genericError)
}
}
return failure
}
},
[genericError, setReclaim, refreshState],
)
return {
state,
storageBusy,
@@ -376,6 +466,9 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
startMigration,
cancelMigration,
refreshStats,
setReclaimDelete,
checkReclaim,
confirmReclaim,
refreshState,
discardDraft,
}
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-PANEL-001 to -017: the storage panel logic behind both admin shells.
// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
@@ -28,6 +28,7 @@ function baseState(): StorageAdminState {
) as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
@@ -57,6 +58,9 @@ function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): Stor
startMigration: vi.fn(async () => null),
cancelMigration: vi.fn(async () => null),
refreshStats: vi.fn(async () => null),
setReclaimDelete: vi.fn(async () => null),
checkReclaim: vi.fn(async () => null),
confirmReclaim: vi.fn(async () => null),
refreshState: vi.fn(async () => {}),
discardDraft: vi.fn(async () => {}),
};
@@ -282,3 +286,48 @@ describe('migrationPromptLine', () => {
).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}');
});
});
describe('useStoragePanel: unreferenced files', () => {
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
fake.admin = makeAdmin(baseState());
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.handleReclaimDelete(true);
});
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
// A failure without a code the panel knows shows the server's message.
expect(toast.error).toHaveBeenCalledWith('disk full');
expect(result.current.reclaimSaving).toBe(false);
await act(async () => {
await result.current.handleReclaimCheck();
});
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
// A code the panel knows is shown in the operator's language.
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
expect(result.current.reclaimChecking).toBe(false);
});
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
const state = baseState();
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
fake.admin = makeAdmin(state);
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaimChecking).toBe(true);
await act(async () => {
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
});
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
expect(result.current.reclaimConfirming).toBeNull();
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
});
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaim.deleteEnabled).toBe(false);
expect(result.current.reclaim.checkedAt).toBeNull();
expect(result.current.reclaim.lines).toEqual([]);
});
});
@@ -7,11 +7,13 @@ import type {
StorageConfig,
StorageConfigPut,
StorageMigrationStatus,
StorageReclaimConfirmRequest,
} from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { formatBytes } from '../../../utils/formatBytes';
import { useToast } from '../../shared/Toast';
import { reclaimView } from './reclaimModel';
import {
adoptedMirrorFor,
computeMigrationCandidates,
@@ -31,7 +33,7 @@ import {
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel';
import { useStorageAdmin } from './useStorageAdmin';
import { useStorageAdmin, type ReclaimFailure } from './useStorageAdmin';
/** Display-name mapper for joined category lists: the raw id renders only in the badge. */
export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
@@ -165,6 +167,35 @@ export function useStoragePanel() {
if (error) toast.error(error);
};
// The click itself, until the server answered; the pass that follows shows through `reclaim.running`.
const [reclaimStarting, setReclaimStarting] = useState(false);
const [reclaimSaving, setReclaimSaving] = useState(false);
const reclaimError = (failure: ReclaimFailure) =>
'code' in failure ? t(`storage.reclaim.error.${failure.code}`) : failure.message;
const handleReclaimCheck = async () => {
setReclaimStarting(true);
const failure = await admin.checkReclaim();
setReclaimStarting(false);
if (failure) toast.error(reclaimError(failure));
};
const handleReclaimDelete = async (enabled: boolean) => {
setReclaimSaving(true);
const failure = await admin.setReclaimDelete(enabled);
setReclaimSaving(false);
if (failure) toast.error(reclaimError(failure));
};
// The category whose confirmation is on its way, so its button waits for the answer.
const [reclaimConfirming, setReclaimConfirming] = useState<StorageReclaimConfirmRequest['category'] | null>(null);
const handleReclaimConfirm = async (request: StorageReclaimConfirmRequest) => {
setReclaimConfirming(request.category);
const failure = await admin.confirmReclaim(request);
setReclaimConfirming(null);
if (failure) toast.error(reclaimError(failure));
else toast.success(t('storage.reclaim.confirmed'));
};
const reclaim = reclaimView(admin.state?.reclaim ?? null);
const reclaimChecking = reclaimStarting || reclaim.running;
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return;
setSyncPrompt((prev) => (prev === row.name ? null : prev));
@@ -371,6 +402,13 @@ export function useStoragePanel() {
migrationQueue,
handleCancelMigration,
handleRefreshStats,
reclaim,
reclaimChecking,
reclaimSaving,
reclaimConfirming,
handleReclaimCheck,
handleReclaimDelete,
handleReclaimConfirm,
handleStartBackfill,
handleCancelBackfill,
loaded,
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useFormatDate } from '../../hooks/useDateFormat';
export interface AuditEntry {
id: number;
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const { formatDateTime } = useFormatDate();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
@@ -1,6 +1,7 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
return formatDateTime(new Date(dateStr), opts);
} catch {
return dateStr;
}
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { useSettingsStore } from '../../store/settingsStore';
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
@@ -11,7 +12,13 @@ import {
normalizeStyleForProvider,
styleSettingKey,
} from '../Map/glProviders';
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
styleForProvider,
} from '../Settings/mapSettingsModel';
import { useToast } from '../shared/Toast';
import type { RoutingDefaults } from './RoutingInstanceFields';
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
date_format?: DateFormat;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
const [mapboxToken, setMapboxToken] = useState('');
const [cartoKey, setCartoKey] = useState('');
const [mapboxStyle, setMapboxStyle] = useState('');
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
defaults,
loaded,
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, operatorMapboxToken),
isSet,
save,
reset,
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -1,5 +1,7 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
} catch { return '' }
}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { relativeTime } from '../../../utils/relativeTime'
import { Tooltip } from '../../shared/Tooltip'
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
const { t, language } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
}
/**
+7 -4
View File
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no link into it.
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
{(guide.link || docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
{(guide.link || docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
{docs && (
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
)}
</div>
)}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no links into it.
const managed = useAuthStore(s => s.managed)
const closeHelp = useHelpStore(s => s.closeHelp)
const screenRoute = useScreenRoute(context?.route)
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const docs = onePerPage(context.docs)
const docs = managed ? [] : onePerPage(context.docs)
const trail = helpContextTrail(context).slice(0, -1)
const Icon = helpIcon(context.icon)
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
// No Help & Docs page on a managed install (App.tsx): guides only.
const managed = useAuthStore(s => s.managed)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
return (
<div className="trek-help-view flex flex-col gap-8">
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
</section>
)}
{!managed && (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
</ul>
)}
</section>
)}
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
@@ -102,6 +103,8 @@ beforeEach(() => {
// The time field reads time_format, so pin it: without this the cases that
// assert a 24h string depend on whichever test ran before them (#2067).
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
// A photo provider switched on for the install, so the External photos source is offered.
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
seedStore(useAddonStore, { addons: [] })
mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
import { useAddonStore } from '../../store/addonStore'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t } = useTranslation()
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Camera size={14} />
</button>
</Tooltip>
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
{/* Only where the install has a photo provider (Immich, Synology) switched
on, as on the phone: a source that can only say "nothing connected"
is not worth its width, and the other two share the row instead. */}
{photoProvidersOn && (
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
)}
</div>
{/* The gallery picker, directly below the buttons. Safari collapses
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
)}
{photoTab === 'external' && (
{photoTab === 'external' && photoProvidersOn && (
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
<div className="min-w-0">
@@ -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);
};
}
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
}, dateFormat)
} catch {
return d
}
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const dateFormat = useDateFormat()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
</span>
</span>
{already && (
@@ -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} />}
@@ -1,12 +1,15 @@
import { createPortal } from 'react-dom'
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
import type { SidebarState } from './usePlacesSidebar'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function MobileDayPickerSheet(S: SidebarState) {
const {
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
} = S
const dateFormat = useDateFormat()
return createPortal(
<div
role="presentation"
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>
@@ -1,6 +1,7 @@
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n/TranslationContext';
import { chargingRepo } from '../../repo/chargingRepo';
import { useTripStore } from '../../store/tripStore';
@@ -76,6 +77,7 @@ export default function ChargingInfo({
}: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore((s) => s.trip?.id);
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const [info, setInfo] = useState<Info | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -133,7 +135,7 @@ export default function ChargingInfo({
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
if (compact)
return (
<ChargingBadges
@@ -211,7 +213,7 @@ export default function ChargingInfo({
</a>
<br />
{info.license}
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
</p>
)}
</div>
@@ -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)
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { useTranslation } from '../../i18n'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
}
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
function formatStamp(ts: string, locale: string): string {
function formatStamp(ts: string, locale: string, dateFormat: string): string {
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
}
/**
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
const { t } = useTranslation()
const dateFormat = useDateFormat()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 px-3.5 py-3">
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
</code>
</div>
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
{', '}
{apiKey.last_used_at
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
: t('settings.apiKeys.neverUsed')}
</p>
{/* What the key may read. Shown on the row rather than behind a detail
@@ -1,6 +1,7 @@
import React from 'react'
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const S = useDawarichConnection()
return (
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
<DawarichConnectionStatus state={S} />
</Section>
)
}
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
{state.connected && (
<p className="m-0 text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
</p>
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
});
});
// ── Date format (056, 057) ────────────────────────────────────────────────────
describe('DisplaySettingsTab date format (#2971)', () => {
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
render(<DisplaySettingsTab />);
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByText('31.12.2026'));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
await user.click(await screen.findByText('31/12/2026'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
}
/>
{/* Date format: every date field and every date written in numbers (#2971) */}
<SettingRow
label={t('settings.dateFormat')}
hint={t('settings.dateFormatHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.date_format || DEFAULT_DATE_FORMAT}
onChange={v => {
void save('date_format', String(v))
}}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
}
/>
</SettingRows>
</Section>
@@ -1,6 +1,7 @@
import Section from './Section'
import React, { useId } from 'react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import PhotoProvidersSection from './PhotoProvidersSection'
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const { formatDate } = useFormatDate()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const expanded = !!oauthScopesExpanded[client.id]
return (
<div className={`${ROW} items-start`}>
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
function OAuthTab({ S }: { S: IntegrationsState }) {
const {
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
} = S
return (
<>
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
</p>
</div>
<button type="button" onClick={() => setOauthRevokeId(session.id)}
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
function ApiTokensTab({ S }: { S: IntegrationsState }) {
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
return (
<>
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="font-geist">{token.token_prefix}...</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
{token.last_used_at && (
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
)}
</p>
</div>
@@ -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 {
@@ -2,9 +2,11 @@ import { escapeHtml } from '@trek/shared';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { adminApi, authApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage, type UserWithOidc } from '../../types';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { passwordErrorKey } from '../../utils/passwordError';
import { useToast } from '../shared/Toast';
@@ -35,6 +37,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation();
const dateFormat = useDateFormat();
const toast = useToast();
const avatarInputRef = useRef<HTMLInputElement>(null);
@@ -126,7 +129,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
if (skipBackupCodes) return;
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(formatDisplayDateTime(new Date(), undefined, {}, dateFormat))}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) return;
w.document.open();
@@ -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,
@@ -1,8 +1,10 @@
import { startRegistration } from '@simplewebauthn/browser';
import { useEffect, useState } from 'react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { readDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { useToast } from '../shared/Toast';
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
@@ -10,7 +12,7 @@ export function fmtDate(ts: string | null): string | null {
if (!ts) return null;
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
return Number.isNaN(d.getTime()) ? null : formatDisplayDate(d, undefined, {}, readDateFormat());
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { pluginsApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { formatDisplayDateTime } from '../../utils/dateFormat';
export interface ActivityRow {
ts: string;
@@ -18,6 +20,7 @@ export interface ActivityRow {
*/
export function usePluginActivity() {
const { locale } = useTranslation();
const dateFormat = useDateFormat();
const [rows, setRows] = useState<ActivityRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +39,7 @@ export function usePluginActivity() {
const fmtWhen = (ts: string): string => {
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
return Number.isNaN(d.getTime()) ? ts : formatDisplayDateTime(d, locale, {}, dateFormat);
};
return { rows, loading, load, fmtWhen };
@@ -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
@@ -18,8 +18,10 @@ import {
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
@@ -428,12 +430,13 @@ function SnapshotNotice({
onOpen: (placeId: number) => void;
}) {
const { t } = useTranslation();
const dateFormat = useDateFormat();
const snapshot = preview.snapshot;
if (preview.state === 'new' || !snapshot) {
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
}
const when = snapshot.importedAt
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
? formatDisplayDate(new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`), locale, {}, dateFormat)
: '';
const open = (
<button
@@ -78,6 +78,19 @@ afterEach(() => {
delete window.__addToast
})
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('VacaySettings calendars', () => {
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
expect(hire()).toHaveAccessibleName(/2020/)
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
typeDate(hire(), '2021-03-02')
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
})
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
clearDate(hire())
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
@@ -3,6 +3,7 @@ import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRigh
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import type { VacayHolidayCalendar } from '../../types'
@@ -355,12 +356,12 @@ function YearTypePicker() {
{type === 'anniversary' && (
<div style={{ paddingInlineStart: 36, marginTop: 8 }}>
<input
type="date"
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
style={{ width: '100%' }}
/>
{!yearSettings.hire_date && (
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>

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