Compare commits

..

144 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

For every install:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude Sonnet 5.5 at times ends a turn on suggest_follow_ups alone, without
writing its answer, and the turn failed as empty_answer. That step now goes
back to the model whole, its follow-ups answered that the user saw nothing
yet, and their chips come under the answer it then writes. The prompts no
longer say the call goes alone in its step.
2026-11-01 10:18:27 +01:00
Maurice cafc8dd46e fix: link dataforest cloud with /en again
The 5.1.0 popup, the sponsor panel in Settings > About and the README had
switched to https://cloud.dataforest.net/?mtm_campaign=trek. The link stays
https://cloud.dataforest.net/en?mtm_campaign=trek everywhere.
2026-10-31 12:26:36 +01:00
github-actions[bot] dc36c21c08 chore: bump version to 5.1.0 [skip ci] 2026-10-31 11:12:52 +00:00
Maurice 175413b3c1 ci(release): take ci-ok from an earlier commit when main only changed workflows [skip ci]
A fix to a release workflow changes nothing that the tests cover, yet the
gate wanted a full new test run on it. With ci_from the release takes ci-ok
from the commit main's code comes from, provided that commit is an ancestor
of main, everything outside .github/ is identical, and one of its ci-ok runs
is green.
2026-10-31 12:10:35 +01:00
Maurice 030918cfa9 ci(release): check the dialect with node's types
TypeScript 6 includes no @types unless a file imports from them, and the
issued dialect imports nothing from node. Type-checked on its own it did
not know Buffer, so the 5.1.0 release threw away a valid dialect as one
that does not type-check. The check config names node now.
2026-10-31 12:09:32 +01:00
Maurice 75d65ccb1b test(release-notes): build the new cases without non-null assertions
The four cases added with the 5.1.0 popup patched the fixture through
n.release! and read the support card through card!, six warnings over the
tests baseline of the eslint ratchet. They go through a withRelease()
helper and optional chaining instead.
2026-10-31 11:31:13 +01:00
Maurice def8b8a24d release notes: the 5.1.0 popup, and dataforest cloud in Settings > About
The release popup now shows 5.1.0: place search, shared place details
and faster routing as three cards side by side, each with its own
drawing. The support band is set as a card like the promise above it
(optional supportLabelKey; a release without it keeps the plain line),
and the two support buttons get the same glass look in their colours.

Settings > About gets a full-width panel for dataforest cloud, who
sponsor the servers behind place search, address search and routing.
Both logos ship in client/public/brands, so opening Settings reaches no
third party. Wherever a line names dataforest cloud it links to
https://cloud.dataforest.net/?mtm_campaign=trek, the link dataforest
gave us; the README moves to it too.

All 28 locales carry the new copy, and the old 5.0 card keys are gone.
2026-10-31 11:04:19 +01:00
Maurice f4e524f3c3 place-enrichment: the details lookup is a standalone cache fill
The service reads a community place's details before the result cache and
every other place's after it, so the lookup has two call sites of which one
runs. It only fills the details row cache and turns a failure into no
details, so it is tagged @txStandalone and the enrich baseline drops to the
four writes it holds without it.
2026-10-31 06:45:13 +00:00
Maurice 682ae19143 test(place-enrichment): a cached OSM place still reads its details for the community version
An OSM element can hold community values, so the service reads its details
before the cache to learn the version it is served under. The second request
reads them once and then answers from the row cache without another lookup.
2026-10-31 06:27:31 +00:00
Maurice 255607b750 place-contrib: format the settle filters of the outbox repository
The id and cid lists the settle writes match on come from two small helpers,
which keeps each filter on one line in Prettier's layout.
2026-10-31 06:27:31 +00:00
Maurice ce7437a46f i18n: translate the place sharing, services card and refresh pill strings into Hebrew
The Hebrew locale arrived on dev while this branch was open, so its 80 new
admin.contribute, admin.servicesCard and places.refresh keys were missing
there.
2026-10-31 03:25:56 +00:00
Maurice 36d8cc2e7b wiki: the New place details pill offers the description too 2026-10-31 03:25:56 +00:00
Maurice dc76efc418 admin: a withdrawal that went through is reported even when the state re-read fails 2026-10-31 03:25:56 +00:00
Maurice 314b087f98 planner: the refresh pill compares with the place picked anew in the search box
After another place was picked while editing, the pill still offered the
saved place's details and would have written them onto the new one.
2026-10-31 03:25:56 +00:00
Maurice 2f689ea9ca maps: stop reading a community picture at its size cap
An answer without a content-length was buffered whole before its size was
checked. The read now stops at the cap.
2026-10-31 03:25:56 +00:00
Maurice 41edbcf67a maps: validate the community values and OSM elements the TREK Places API answers
Both answers were taken as typed. A value outside the published shape, such
as hours that are no text, reached the hours parser and failed the details
request. A place outside the shape is now left out of /v1/community, and an
element outside it sends the lookup to Overpass.
2026-10-31 03:25:56 +00:00
Maurice 6945b53c10 place-enrichment: a community summary the request carried stays out of the shared cache
Like an OpenStreetMap summary, a summary credited to the community is read
straight off the request's details, so any caller could have it served to
every user of the instance for a week under the community's name.
2026-10-31 03:25:56 +00:00
Maurice 7ade99c8ce place-contrib: audit a switch-off whose withdrawal the service did not confirm
Switching sharing off commits before the withdrawal asks the service. When
the service did not confirm, the decision had taken effect but left no audit
row. It is now audited with withdrawFailed, and the admin still gets the
error.
2026-10-31 03:25:56 +00:00
Maurice 593df53993 places: a picture route records the origin of the picture only
The upload and the trip-file routes vouched for every field that changed
between their read and their answer. A website the assistant or a
collaborator changed while a large picture uploaded was then recorded as
typed by the user and queued for the TREK Places API. Those writers now name
the one field they write, and other changes keep the origin their own writer
recorded.
2026-10-31 03:25:56 +00:00
Maurice aa41c67a20 place-contrib: settle a sent value only while its queue row still holds it
A value saved while the request for its predecessor was out rewrote the same
outbox row with a new cid; the answer for the old value then deleted the row,
or charged it an attempt, and the newer value was never sent. The settle
writes now match the cid that was sent as well as the id, so such a row stays
queued for the next run. A replaced value also counts its age from the
replacement, so a long pause cannot expire a value queued days ago.
2026-10-31 03:25:56 +00:00
Maurice c10a698b9f planner: keep a place's enrichment in the tab for ten minutes, not for good
A saved place reopened for editing has no community version in its key, so
the answer the tab kept the first time stayed until the tab closed: a picture
another installation contributed since never showed. Kept answers now carry
when they were fetched and are asked again after ten minutes, the same span
the server keeps an empty answer. The key version moves to 5 so open tabs drop
what they kept in the old shape.
2026-10-31 03:25:56 +00:00
Maurice e2756dab22 places: a picture another installation contributed shows on the next edit
Two gaps kept it out. The service writes absolute picture URLs on its own
host, and the community service only took relative paths, so every picture
was dropped; it now takes the path of a URL on the configured service and
nothing from another host. And a saved place opened for editing sends no
details, so no community version, and the cached answer kept for a week; for
an index or OSM place the details are now read before the cache, so a new
version is a new answer.
2026-10-31 03:25:56 +00:00
Maurice 330644e88b places: show the community's published pictures first in Pick a picture
The service delivers the pictures review approved for a place, but TREK never
read them. A new TrekCommunityService in the maps domain asks the TREK Places
API for them (the index row for a gers place, /v1/community for an OSM one),
accepts only paths of the service's picture store, and fetches the bytes with
the installation's key, capped at 4 MB. Enrichment puts them ahead of Google
and Commons, through the same photo cache, credited as TREK Community under
CC BY 4.0. The enrichment cache now follows the community version for OSM
places too, so a newly approved picture shows on the next open.

Also lowers the client's no-non-null-assertion baseline to 380: moving Use
this text into the description card removed two.
2026-10-31 03:25:56 +00:00
Maurice 0b86a13b51 admin: fold the transmission log to its newest five rows
A busy log pushed the rest of the card down. It shows the newest five rows,
a Show more toggle opens the rest, and the page buttons appear once it is
open.
2026-10-31 03:25:56 +00:00
Maurice 8d8629516b places: offer a newer description in the refresh pill
The pill compared website, phone, e-mail and hours only, so a description
the community published never showed up on a saved place. It now reads the
index description or the OSM summary, compares it as plain words against
the editor's markup, and shows it clamped to three lines.
2026-10-31 03:25:56 +00:00
Maurice 5822910f40 places: put Use this text right under the description it copies
The button was pinned to the foot of the details column, away from the text
it adopts. It now sits inside the description card, under the source line,
with the reason it is blocked directly beneath it.
2026-10-31 03:25:56 +00:00
Maurice ce5ec7132f places: a description the community shared is credited to the TREK community
A community description laid over OSM details or an index row showed up as
OpenStreetMap under ODbL, or as the place's own website. It now has a source of
its own, community, credited as TREK Community under CC BY 4.0, like the
service publishes it.
2026-10-31 03:25:56 +00:00
Maurice 7f2a3b6039 planner: keep REFRESH_FIELDS inside its module; docker: leave shared/node_modules out of the build context
A checkout where npm install ran once carried shared/node_modules into the
image build, and its stale packages broke the client build with a vite that
lacked vite/internal.
2026-10-31 03:25:56 +00:00
Maurice 3e860dd9ca place-contrib: tag enqueue's send run as independent of its queue write 2026-10-31 03:25:56 +00:00
Maurice c28604b891 planner: the details refresh reads through a repository, and the form drops what moved out
The fresh lookup behind New place details goes through placeDetailsRepo
(online-only, like the service identity) instead of api/ directly. The form
loses the Translate type and the comment that moved with the head band's
pills, which takes it under its line limit and out of the size baseline; the
maps service suite's test-size entry follows its shorter count.
2026-10-31 03:25:56 +00:00
Maurice aba329b1b1 planner: a saved place offers newer details from its provider, field by field
Editing a saved place looks it up again. Where the lookup has a website,
phone, e-mail or opening hours that differ from the place, a New place details
pill in the form's head band shows how many, and opens what the place holds
beside what the lookup has. Empty fields are ticked, filled ones are left to
the person and say they would replace; Take over writes only the ticked
fields, with the provider as their origin, and nothing the lookup does not
know is ever emptied.

The head band's pills move to PlaceFormHeaderPills.tsx, which brings the form
from 1157 to 1002 lines.
2026-10-31 03:25:56 +00:00
Maurice 04458bee12 place-contrib: community hours in the details' own shape, and no origin rows for collection copies
Community opening hours laid over OSM details now go through the same parser
as OSM's own, so they arrive as one line per day with open-now and the periods,
and a community description lands in the details' summary. A place copied from
a collection no longer records its values as an import: without an origin row a
value is never sent anyway, and a later edit marks only the fields it changes.
2026-10-31 03:25:56 +00:00
Maurice f01ffc2c67 maps: OSM details come from TREK's own copy of OpenStreetMap first
Opening an OSM place asked the public Overpass servers for its tags, which
took five seconds and more and answered nothing while they were down. Where
the TREK services are in use, the details now come from the Places API's
details layer (/v1/osm), with the community values in the same answer, and
Overpass is asked only for an element the layer does not hold.
2026-10-31 03:25:56 +00:00
Maurice 5cfe925b78 place-contrib: no promise in the job's condition; the OSM details test counts the community call 2026-10-31 03:25:55 +00:00
Maurice a2414685ad wiki: what the API Keys card still saves 2026-10-31 03:25:55 +00:00
Maurice a1ca1aae44 admin: place search and routing get a card of their own, the keys keep theirs
The TREK API, the transit provider, the place search provider and the search
log move out of API Keys into a card of their own, which keeps them together
and saves as they change. API Keys holds only Google Maps, Unsplash and Amap,
and is not shown on a managed install, where the keys are the operator's.
Allowed File Types moves to the left column, which had room for it.
2026-10-31 03:25:55 +00:00
Maurice 56536ae803 admin: the allowance and the sharing blocks take the settings' own card head
Both blocks in the TREK API card now use SettingsCard like Single Sign-On and
two-factor: an icon tile, the title with its state as a badge, the note under
it and the switch or badges on the right, with a flush variant for a card
inside a card. A figure badge no longer cuts the last pixels of its value, and
the withdrawal toast names how many values it took back.
2026-10-31 03:25:55 +00:00
Maurice 59154b34c9 maps: OSM details carry the values installations published for the element
A place found through Photon or Overpass, a relation or a way, is no row of the
TREK index, so the community values the index lays over its own rows never
reached it. Opening its details now asks /v1/community for the element and
lays what is published over the record, with the version, where the TREK index
is in use. No answer leaves the details as they were.
2026-10-31 03:25:55 +00:00
Maurice b64f4535fd admin: set the service card's state and its one action apart from the figures
The state and its badges sit in a head band, the new-identity button in a foot
band, each with a rule and the card's own surface, so neither reads as part of
the allowance tiles between them.
2026-10-31 03:25:55 +00:00
Maurice ad2916903c place-contrib: send a queued value at once instead of minutes later
A value is due the moment it is queued, and the save starts a send run right
away; the two-minute tick stays for the retries. One run goes at a time, and a
run asked for meanwhile follows right after it. Only where the schedules run, so
tests and replicas send nothing.
2026-10-31 03:25:55 +00:00
Maurice 71cd6c2de4 place-contrib: record origins for collection copies and booking imports
A place copied from a collection and a venue a booking import creates now
record their details as an import, so a later edit compares against what was
really written. Dawarich and the background enrichment write none of the
shareable fields, so they need nothing.

The admin panel asks again for a few seconds while a fresh installation is
still fetching its key, so the prompt appears without a reload.
2026-10-31 03:25:55 +00:00
Maurice 3f2dec6c47 place-contrib: sort the request id's parts with an explicit code-unit compare 2026-10-31 03:25:55 +00:00
Maurice 668df8c62e place-contrib: seed the repository tests through the row factories, not raw SQL 2026-10-31 03:25:55 +00:00
Maurice 294ba0a4fe place-contrib: repository tests for the log, the outbox and the field origins
Every method of the three new repositories, the empty-list shortcuts and the
filters included, so the repositories keep their 99 % floor.
2026-10-31 03:25:55 +00:00
Maurice 36c1599a09 place-contrib: settle the second CI round
- The plugin RPC fallback tests build PlacesRpc with its origin bookkeeping.
- The e2e guard and contract cases send their requests one after another; the
  test server reset connections opened all at once.
- The job's two cron registrations are tagged @txIndependent: neither writes.
- The panel draws its fold head itself instead of importing it from pages/.
2026-10-31 03:25:55 +00:00
Maurice 65abea6910 place-contrib: settle the first CI round
- The pause setting's type comes from its schema, so nullable fields read the
  same under the server's compiler settings.
- The place form passes the origin of a field through to the hook.
- Serial steps run through inOrder (src/nest/common/in-order.ts) and
  independent ones through Promise.all, instead of an await inside a loop. The
  bulk update in places.mcp.ts goes the same way, so its baseline drops by one.
- The status a call without a key gets is a named constant, not an envelope
  literal.
- A picture test no longer inherits the previous test's mock answer.
- The entity counts include the three new tables.
- The log keeps its failure per query instead of resetting it in the effect,
  and usePlaceForm sets its picture through one helper.
2026-10-31 03:25:55 +00:00
Maurice 18dc121132 wiki: sharing place details with the TREK Places API
What is sent and when, what never is, how values are checked before anyone
sees them, and where an admin switches it off or takes it back. The new
TREK_PLACES_CONTRIBUTE variable is listed with the compose file and the chart.
2026-10-31 03:25:55 +00:00
Maurice 013aa3954d admin: ask once, then show and steer what is shared of place details
The first time an admin opens the settings on an installation that can share,
a dialog asks for the whole installation, with every field switched on and a
plain list of what never goes along. Nothing is sent before Agree; Not now
asks again in 14 days, No keeps the question away until the terms change, and
closing it decides nothing.

Inside the TREK Places API card, Share place details then holds the switch for
the installation and one per field, the figures of the last 30 days, the
transmission log and the way to withdraw everything sent. The place form
reports for each field whether the user typed it or a provider filled it.
2026-10-31 03:25:55 +00:00
Maurice 182ed56fa4 places: let an admin share the details users typed with the TREK Places API
Every shareable field of a place now records where its value came from: typed
by a user, filled by a provider, written by the assistant, a plugin or an
import. Only values a user typed are ever sent, and only after an admin agreed
to the current terms for the whole installation, field by field.

The new place-contrib domain keeps the decision in app settings, queues values
in an outbox a cron job sends from, logs what the service made of each one and
can withdraw everything this installation sent. Development, test and demo
instances, an own TREK_PLACES_URL, TREK_SERVICES=off and
TREK_PLACES_CONTRIBUTE=off never send anything. Index results carry the
community version, so the details caches refresh when it changes.
2026-10-31 03:25:55 +00:00
Vini f56f6ae11e perf(atlas): keep the admin0 country index as Int16 arrays and send countries/geo from disk 2026-10-31 03:25:13 +00:00
Vini c94ff7421a keep looked-up regions as packed Int32 arrays under a 16 MiB budget 2026-10-31 03:25:12 +00:00
Maurice 2dbcc39832 chore(lint): lower the no-explicit-any baseline after the typed region lookup 2026-10-31 03:25:09 +00:00
Maurice 47dad114df test(atlas): cover the per-country region route in the e2e suite 2026-10-31 00:40:23 +00:00
Vini 7277e35a96 serve each country's regions from its own file, so the server no longer holds them all
The Atlas server no longer keeps the admin-1 regions of every country in memory. Each country's regions are now a pre-gzipped file of their own, sent to the map as they are.

**Why:** `GET /addons/atlas/regions/geo?countries=…` built `admin1Store` (every country's region text, kept until restart), then joined the requested countries, `JSON.parse`d them, and Nest stringified them again. On the 512 MB – 1 GB boxes TREK runs on, the first z≥5 view could OOM the server (same shape as #1576).

**What changes:**
- `server/assets/atlas/admin1/<ISO_A2>.geojson.gz`: 198 files, 14.85 MB, the same 3,447 features as `admin1.geojson.gz`.
- New `GET /addons/atlas/regions/geo/:code` with a Zod contract (`regionGeoParamsSchema` in `@trek/shared`: two letters, any case). It sends the country's file as it is, with `Content-Encoding: gzip` and the current 1-day `Cache-Control`, as `countries/geo` does. A country without regions gets an empty FeatureCollection. Anything that is not two letters gets a 400.
- `useAtlas.ts` fetches one URL per uncached country and still rebuilds the layer once. Each country has a stable URL, so a country evicted by `REGION_CACHE_MAX` comes back from the browser cache.
- `getRegionFeatures` reads only the country it needs. `admin1Store` is removed.
- `?countries=` keeps working for cached PWA clients. It joins the per-country files as bytes, without a parse.
- The migrations are untouched. `admin1.geojson.gz` still ships, only for `Migration20200101021500` and `Migration20200101031500`. This costs ~15 MB of disk in the image and no RAM. `build-atlas-geo.mjs` writes both, and ATLAS-BUNDLE-008 keeps them equal.

**Memory** (real `atlas-geo` module, first z≥5 view over Europe = 49 country codes, JS heap + Buffers):

| | peak | kept after |
|---|---|---|
| before, first view (builds the store) | +372 MB | +61 MB |
| before, later views | +115 MB | 0 |
| after, `/regions/geo/:code` | +4.7 MB | 0 |
| after, legacy `?countries=` | +59 MB | 0 |

**Tests:**
- New: `atlas-region-files.test.ts` (moved from `atlas.service.test.ts`, plus join and dedupe cases), the `regionGeoParamsSchema` spec, ATLAS-BUNDLE-007 (the file format the byte join depends on), ATLAS-BUNDLE-008 (bundle and per-country files are equal), ATLAS-016/017/018 (the new route over HTTP, including the 400).
- The `getVisitedRegions` tests (ATLAS-UNIT-019..024) used to pass only when `getRegionGeo`'s `beforeAll` had built the store first. They now also pass on their own.
- The shared, server and client gates pass. Baselines go down only where this change removed code (`no-console`, `no-explicit-any`, `no-swallowed-catch`, file sizes).

**Trade-offs:**
- A first zoom over Europe sends ~49 small parallel requests instead of 1. Over HTTP/1.1 the browser queues them 6 at a time. The total bytes are the same (4.4 MB gz).
- The per-country URLs are unversioned and cached for a day, like `countries/geo`. After an upgrade that changes a country's regions, a browser can show the old shapes for up to 24 h.

Approved on Discord in #github-pr: <paste the thread link here>
Related: #1576, #2559

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [x] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [x] I have tested my changes locally
- [x] I have added/updated tests that prove my fix is effective or that my feature works
- [x] I have updated documentation if needed
2026-10-31 00:40:23 +00:00
Maurice 70f27085b9 feat(i18n): bring the Hebrew locale up to date with dev and list it in the docs
Translate the 55 keys dev added since the branch was cut (TREK services
allowance, Valhalla routing service, PDF routing progress, overview
distances, Unsplash key hint, tour day limit) and drop the two reservation
toasts dev removed, so he matches en key for key again. Refresh the two
admin strings and the Valhalla hint whose English changed.

Check he in the placeholder spec and the RTL test, and count 28 languages
with Hebrew in the README and the Languages, Environment Variables,
Contributing and Help Center wiki pages.
2026-10-31 00:40:23 +00:00
Dolev Benezri 156dc4a6c2 feat(i18n): complete Hebrew locale for dev
Fill in every domain added since the first Hebrew pass (help, tours,
assistant*, studio*, wanderer*, journey, demo, ferratas and the rest),
retranslate strings whose English changed, and give every count group
its Hebrew one/two forms. Register he as a non-Latin locale for the
untranslated ratchet, add the Hebrew 429 message, and count 28 locales
in the tours locale spec.
2026-10-31 00:40:23 +00:00
Dolev Benezri d159aec78b feat(i18n): add Hebrew (he) locale with RTL support
Full Hebrew translation of every UI domain plus email/notification
templates. Registered in SUPPORTED_LANGUAGES, marked right-to-left,
wired into the client locale loader and the rate-limit message table.
2026-10-31 00:40:23 +00:00
bear 547f0b84b0 fix(mcp): file a transit fare under the transport cost category
transit is not one of the fixed Costs categories, so typeToCostCategory
would put an uncategorised fare under other. Default to transport, the
category every rail and bus booking maps to.
2026-10-31 00:40:23 +00:00
bear 8d10bff616 feat(mcp): price on create_transit_journey, reservation_id on create_budget_item
- create_transit_journey takes price / budget_category, as create_transport
  does: a fare > 0 files the linked expense in the booking's own transaction
  and rides along in metadata.price. The tool now writes through
  ReservationWritesService.createBooking, so its events and booking notice are
  the ones every other booking write sends
- create_budget_item and create_budget_item_with_members take reservation_id,
  the field POST /budget already accepts; create_budget_item now runs
  BudgetWritesService.createItem like its sibling, so a linked booking's
  mirrored price is worked out again before budget:created goes out
- bookingPriceSchema / bookingBudgetCategorySchema in @trek/shared back the
  price fields of all three booking tools (mcp-inline-zod baseline lowered)
- wiki: MCP-Tools-and-Resources describes the new params
2026-10-31 00:40:23 +00:00
Maurice e5048643f8 test(assistant): pin the clock in FE-AI-PICK-004
Trip 7 ran until 2026-10-30, so from 2026-10-31 on the hook ranked it as
past and the test failed. Pin the date to the file's TODAY.
2026-10-31 00:39:38 +00:00
Maurice a156f5be32 fix(chart): keep a blank line before the HTTPRoute section in NOTES.txt
The trimming if swallowed the blank line, so the HTTPRoute note ran
straight on from the Persistence section. Move the blank line inside
the conditional so it only appears with the section.
2026-10-30 23:32:43 +00:00
Demisu 431122d253 refactor(README): remove HTTPRoute configuration section for clarity 2026-10-30 23:32:43 +00:00
Demisu bbc8f141e3 feat(helm): add optional Gateway API HTTPRoute support
Add disabled-by-default HTTPRoute support with configurable parent references,
hostnames, paths, annotations and optional timeouts. Route to the existing TREK
Service and keep Ingress support unchanged, including simultaneous use.

Document configuration and extend the existing Helm Chart & Compose job with
HTTPRoute render variants and strict validation using a pinned CRDs catalog.

Approved on Discord:
https://discord.com/channels/1488298068427411591/1553537637455761500

Validation: strict Helm lint, 37 manifests validated without errors or skipped
resources, and both Compose configuration checks passed locally.
Live Gateway WebSocket and streamed assistant response checks were not performed
because no Kubernetes test cluster was configured.
2026-10-30 23:32:43 +00:00
Maurice 01681f57c7 docs(chart): document extraVolumes and extraVolumeMounts in the Helm install guide
The install guide lists extraEnv and extraEnvFrom but had no word on the
new volume values. Add a Further volumes section with the KItinerary
extractor example and point the Reservations-and-Bookings page at it.
2026-10-30 23:30:54 +00:00
aaronspruit 553808eb83 feat(chart): add extraVolumes and extraVolumeMounts
The chart mounts only the data and uploads volumes, and no value adds
another one. Add two lists that go into the Deployment as written:
extraVolumes for the pod, and extraVolumeMounts for the trek container,
next to the data and uploads mounts. Both lists are empty by default,
so the default render does not change.

The values file shows one use: a ConfigMap of KItinerary extractor
scripts, mounted at /usr/local/share/kitinerary/extractors. The chart
README and the Reservations-and-Bookings wiki page tell Helm and Compose
users how to mount extractors there.

The variants render in the Helm Chart & Compose job sets both values,
so kubeconform checks them.
2026-10-30 23:30:54 +00:00
Maurice 324ee69151 fix(transit): mark the plan fallback's quota counts as independent writes
The empty-result fallback can call Google after Transitous, so lint:tx now sees two googleQuota.record() calls in TransitService.plan. Only one of them runs per request, and each is a call counter written after its own upstream request, so it cannot share a transaction with it.
2026-10-30 23:36:35 +01:00
Maurice c4c63ffd62 docs(transit): describe the empty-result fallback between transit providers
Admin settings, transport, environment and MCP pages still said Transitous
is the only fallback and that Google is billed only when picked. Document
that an empty plan asks the other backend, that the Google fallback uses
only an instance key within the daily limit, and mention it in the
search_transit_routes tool description.
2026-10-30 23:01:17 +01:00
Raz Erlich 209f8fe5cc feat(transit): fall back between configured public transit providers 2026-10-28 08:01:53 +02:00
Maurice 9f155a3191 fix(airports): refresh the bundled airports from OurAirports without dropping shipped codes
Logroño-Agoncillo (RJL) and fifteen other airports gained their IATA code in
OurAirports after the bundled list was built, so the airport search and the
code lookup of create_transport could not find them. The list is rebuilt from
the current data. The build now keeps every code the previous list carried
that upstream no longer lists (a scheduled-service flag flipped back, PBI moved
to a new record), since bookings and MCP calls still name them.

Refs #2897
2026-10-30 22:06:07 +01:00
1169 changed files with 50072 additions and 3783 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules
client/node_modules
server/node_modules
shared/node_modules
client/dist
shared/dist
data
@@ -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 }}
+37 -2
View File
@@ -38,6 +38,10 @@ on:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
ci_from:
description: 'Take ci-ok from this earlier commit instead of the one on main, when main differs from it only under .github/ (a fix to a release workflow needs no second test run)'
type: string
default: ''
allow_stale_dialect:
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
type: boolean
@@ -87,6 +91,7 @@ jobs:
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being released
if: inputs.ci_from == ''
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -96,6 +101,36 @@ jobs:
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
# ci_from: main carries only workflow changes on top of a commit that
# already passed. That commit must be an ancestor of main, everything
# outside .github/ must be identical, and one of its ci-ok runs must be
# green (its dev push, typically).
- name: Require a green ci-ok on the commit main's code comes from
if: inputs.ci_from != ''
env:
GH_TOKEN: ${{ github.token }}
CI_FROM: ${{ inputs.ci_from }}
run: |
set -euo pipefail
from="$(git rev-parse --verify "$CI_FROM^{commit}")"
if ! git merge-base --is-ancestor "$from" HEAD; then
echo "::error::$from is not an ancestor of main."
exit 1
fi
outside="$(git diff --name-only "$from" HEAD -- . ':(exclude).github/**')"
if [ -n "$outside" ]; then
echo "::error::main differs from $from outside .github/:"
echo "$outside"
exit 1
fi
green="$(gh api "repos/$GITHUB_REPOSITORY/commits/$from/check-runs?check_name=ci-ok&filter=all&per_page=100" \
--jq '[.check_runs[] | select(.status == "completed" and .conclusion == "success")] | length')"
if [ "$green" -lt 1 ]; then
echo "::error::$from has no green ci-ok."
exit 1
fi
echo "main differs from $from only under .github/, and $from has $green green ci-ok run(s)."
- name: Determine bump type and version
id: bump
env:
@@ -307,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 }}
@@ -366,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({
+3 -1
View File
@@ -120,7 +120,9 @@ jobs:
run: |
set -uo pipefail
cp "$FILE" src/nest/service-identity/registration-dialect.ts
printf '%s\n' '{"extends": "./tsconfig.strict.json", "include": [],' \
# TypeScript 6 includes no @types by default and the dialect imports nothing
# from node, so checked on its own it would not know Buffer: name node here.
printf '%s\n' '{"extends": "./tsconfig.strict.json", "compilerOptions": {"types": ["node"]}, "include": [],' \
'"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json
if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then
echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config."
+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
+30 -2
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
@@ -1263,11 +1263,39 @@ jobs:
--set 'extraEnv[0].name=SESSION_DURATION' \
--set 'extraEnv[0].value=7d' \
--set 'extraEnvFrom[0].secretRef.name=trek-smtp' \
--set 'extraVolumes[0].name=kitinerary-extractors' \
--set 'extraVolumes[0].configMap.name=trek-kitinerary-extractors' \
--set 'extraVolumeMounts[0].name=kitinerary-extractors' \
--set 'extraVolumeMounts[0].mountPath=/usr/local/share/kitinerary/extractors' \
--set 'extraVolumeMounts[0].readOnly=true' \
--set probes.liveness=null \
> "$out/variants.yaml"
helm template trek charts/trek \
--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 \
--set service.port=8080 \
--set-json 'httpRoute={"enabled":true,"parentRefs":[{"name":"gateway","namespace":"gateway-system","sectionName":"https"}],"hostnames":["trek.example.com"],"paths":["/","/api"],"annotations":{"example.com/note":"custom route"},"timeouts":{"request":"0s","backendRequest":"0s"}}' \
> "$out/http-route-variants.yaml"
- name: Check the manifests against the Kubernetes schemas
run: kubeconform -strict -summary "$RUNNER_TEMP"/rendered/*.yaml
# The pinned Gateway API schema adds HTTPRoute validation without skipping unknown resources.
run: >-
kubeconform -strict -summary
-schema-location default
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/041baf4c1d3740ca21461cab765e7432ecd073ef/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
"$RUNNER_TEMP"/rendered/*.yaml
- name: Check every compose file
run: |
+2 -1
View File
@@ -91,7 +91,8 @@ ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# transport search) and atlas/*.geojson.gz + atlas/admin1/*.geojson.gz (Atlas
# country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
+3 -2
View File
@@ -5,8 +5,9 @@ TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz`,
`server/assets/atlas/admin1.geojson.gz` and `server/assets/atlas/admin1/<ISO_A2>.geojson.gz`
(the same regions, one file per country) and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
+6 -6
View File
@@ -200,7 +200,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **28 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), he (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
</td>
@@ -218,7 +218,7 @@ 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">
@@ -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/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>
+6 -3
View File
@@ -5,9 +5,10 @@ This is a minimal Helm chart for deploying the TREK app.
## Features
- Deploys the TREK container
- Exposes port 3000 via Service
- Optional persistent storage for `/app/data` and `/app/uploads`
- Optional persistent storage for `/app/data` and `/app/uploads`, plus extra volumes and mounts
- Configurable environment variables and secrets
- Optional generic Ingress support
- Optional Gateway API HTTPRoute support
- Configurable health checks (liveness/readiness probes) on `/api/health`
## Helm Repository
@@ -27,7 +28,7 @@ helm install trek trek/trek
Or install directly from the local chart:
```sh
helm install trek ./chart \
helm install trek ./charts/trek \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
@@ -47,7 +48,7 @@ See `values.yaml` for more options.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- If exposing TREK through an Ingress or HTTPRoute, set `env.ALLOWED_ORIGINS` and `env.APP_URL` to the public URL. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
@@ -55,3 +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`.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.0.1
version: 5.1.0
description: Minimal Helm chart for TREK app
appVersion: "5.0.1"
appVersion: "5.1.0"
+9
View File
@@ -31,3 +31,12 @@
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
{{- if .Values.httpRoute.enabled }}
6. HTTPRoute:
- The chart creates an HTTPRoute v1 to the existing TREK Service. The Gateway, GatewayClass,
listeners, TLS certificates and allowedRoutes are managed by your cluster operator.
- Ingress and HTTPRoute may be enabled together.
- Timeout fields are Standard Channel in Gateway API v1.2, but controller support varies. Omit
them to use controller defaults; `0s` disables the corresponding timeout where supported.
{{- end }}
+3
View File
@@ -112,6 +112,9 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_CONTRIBUTE }}
TREK_PLACES_CONTRIBUTE: {{ .Values.env.TREK_PLACES_CONTRIBUTE | quote }}
{{- end }}
{{- if .Values.env.TREK_SERVICES }}
TREK_SERVICES: {{ .Values.env.TREK_SERVICES | quote }}
{{- end }}
+13 -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 }}
@@ -141,6 +147,9 @@ spec:
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
@@ -168,3 +177,6 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
+39
View File
@@ -0,0 +1,39 @@
{{- if .Values.httpRoute.enabled -}}
{{- if not .Values.httpRoute.parentRefs }}
{{- fail "httpRoute.parentRefs must include at least one parent" }}
{{- end }}
{{- if not .Values.httpRoute.paths }}
{{- fail "httpRoute.paths must include at least one path" }}
{{- end }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
{{- toYaml .Values.httpRoute.parentRefs | nindent 4 }}
{{- with .Values.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range $path := .Values.httpRoute.paths }}
- matches:
- path:
type: PathPrefix
value: {{ $path | quote }}
backendRefs:
- name: {{ include "trek.fullname" $ }}
port: {{ $.Values.service.port }}
{{- with $.Values.httpRoute.timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
+64 -1
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
@@ -36,7 +64,7 @@ env:
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# NOTE: If using ingress or HTTPRoute, ensure env.ALLOWED_ORIGINS matches the public domains for proper CORS configuration.
# APP_URL: "https://trek.example.com"
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
@@ -113,6 +141,10 @@ env:
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
# TREK_PLACES_CONTRIBUTE: "off"
# Whether an admin may let this installation send the place details its users typed in (website, phone, e-mail,
# opening hours, description, picture) to the TREK Places API. "admin" leaves the choice to the admin panel, where
# it stays off until an admin agrees; "off" takes it away. Defaults to admin.
# TREK_SERVICES: "off"
# Places search identifies this installation with a key it registers by itself on first use and keeps in the
# database. "off" sends no key; searches then use the shared pool. A key issued to you goes under
@@ -260,6 +292,20 @@ extraEnv: []
# name: trek-smtp
extraEnvFrom: []
# Further volumes for the pod. The chart does not create the objects they name.
# extraVolumes:
# - name: kitinerary-extractors
# configMap:
# name: trek-kitinerary-extractors
extraVolumes: []
# Where the trek container mounts them. For example, KItinerary
# loads extractors for the booking import from this path:
# extraVolumeMounts:
# - name: kitinerary-extractors
# mountPath: /usr/local/share/kitinerary/extractors
# readOnly: true
extraVolumeMounts: []
resources:
requests:
cpu: 100m
@@ -280,3 +326,20 @@ ingress:
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
httpRoute:
enabled: false
annotations: {}
parentRefs: []
# - name: gateway
# namespace: gateway-system
# sectionName: https
hostnames: []
# - trek.example.com
paths:
- /
# Timeout fields are Standard Channel in Gateway API v1.2; controller support varies.
# Leave empty for controller defaults. Use "0s" to disable the corresponding timeout.
timeouts: {}
# request: "0s"
# backendRequest: "0s"
+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();
}
});
}
}
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.0.1",
"version": "5.1.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -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",
@@ -0,0 +1,23 @@
<svg width="310" height="48" viewBox="0 0 310 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.9361 44.5592H37.3496V40.5946H38.7949C39.6016 40.5946 40.1393 40.0905 40.1393 38.9477V32.7663C40.1393 30.1462 41.3157 28.7345 43.4989 28.0302C41.3157 27.3243 40.1393 25.9142 40.1393 23.2941V17.1127C40.1393 15.9699 39.6016 15.4673 38.7949 15.4673H37.3496V11.5027H39.9361C42.8252 11.5027 44.8403 13.2825 44.8403 16.1059V22.5898C44.8403 24.8738 46.2184 26.0166 48.5696 26.0166V30.0484C46.2184 30.0484 44.8403 31.1912 44.8403 33.4752V39.9591C44.8403 42.7809 42.8252 44.5607 39.9361 44.5607V44.5592Z" fill="white"/>
<path d="M0 30.0453V26.0135C2.35125 26.0135 3.72931 24.8708 3.72931 22.5867V16.1028C3.72931 13.281 5.74445 11.4996 8.63347 11.4996H11.22V15.4642H9.77472C8.96806 15.4642 8.43028 15.9684 8.43028 17.1096V23.291C8.43028 25.9112 7.25389 27.3228 5.07069 28.0271C7.25389 28.733 8.43028 30.1431 8.43028 32.7633V38.9446C8.43028 40.0874 8.96806 40.5916 9.77472 40.5916H11.22V44.5562H8.63347C5.74445 44.5562 3.72931 42.7763 3.72931 39.9545V33.4706C3.72931 31.1866 2.35125 30.0438 0 30.0438V30.0453Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 0.076416L32.7769 8.5678L28.8169 12.5263L24.284 7.99336L19.7511 12.5263L15.7926 8.5678L24.284 0.076416H24.2855Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 11.7119L34.4055 21.8319L30.4471 25.7904L24.2855 19.6289L18.124 25.7904L14.1655 21.8319L24.2855 11.7119Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2856 23.3475L36.0342 35.0961L32.0757 39.0561L24.2871 31.2675L16.4985 39.0561L12.54 35.0961L24.2887 23.3475H24.2856Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.4867 47.8958V37.3129H27.086V47.8958H21.4867Z" fill="white"/>
<path d="M216.808 36.4329H211.635V23.9357H208.444V19.635H211.635V14.4283H216.808V19.635H220V23.9357H216.808V36.4329Z" fill="white"/>
<path d="M192.671 31.1239H197.575C197.643 32.3003 198.516 33.0046 200.095 33.0046C201.673 33.0046 202.446 32.4332 202.446 31.5593C202.446 31.0552 202.177 30.5174 200.933 30.2485L197.843 29.5763C194.752 28.904 193.241 27.5611 193.241 24.6049C193.241 21.1108 196.231 19.0957 200.263 19.0957C204.294 19.0957 206.881 21.3797 206.881 24.7729H201.977C201.977 23.6302 201.271 22.8586 200.029 22.8586C198.853 22.8586 198.115 23.4636 198.115 24.3711C198.115 25.1442 198.753 25.5811 199.928 25.85L203.052 26.5222C205.807 27.1272 207.319 28.4029 207.319 31.0918C207.319 34.6867 204.262 36.8699 199.894 36.8699C195.527 36.8699 192.671 34.5186 192.671 31.1254V31.1239Z" fill="white"/>
<path d="M182.351 36.8699C177.279 36.8699 173.65 33.1742 173.65 28.0011C173.65 22.8281 177.178 19.0988 182.25 19.0988C187.322 19.0988 190.816 22.5592 190.816 28.0347V29.3456L178.622 29.4128C178.924 31.6296 180.168 32.6715 182.452 32.6715C184.4 32.6715 185.778 31.9657 186.147 30.7572H190.884C190.279 34.4865 186.953 36.8714 182.351 36.8714V36.8699ZM182.284 23.2971C180.235 23.2971 179.092 24.2046 178.689 26.2197H185.677C185.677 24.4383 184.366 23.2971 182.284 23.2971Z" fill="white"/>
<path d="M172.433 19.6351V24.5056H170.787C167.831 24.5056 166.051 25.8164 166.051 29.1424V36.433H160.878V19.6687H165.749L166.017 22.1544C166.723 20.5074 168.201 19.3998 170.453 19.3998C171.058 19.3998 171.728 19.467 172.434 19.6351H172.433Z" fill="white"/>
<path d="M139.367 28.0011C139.367 22.6936 143.264 19.1323 148.639 19.1323C154.014 19.1323 157.878 22.6936 157.878 28.0011C157.878 33.3086 153.98 36.8362 148.639 36.8362C143.298 36.8362 139.367 33.3086 139.367 28.0011ZM144.574 27.9675C144.574 30.5204 146.219 32.2009 148.639 32.2009C151.059 32.2009 152.671 30.5219 152.671 27.9675C152.671 25.413 151.025 23.7676 148.639 23.7676C146.253 23.7676 144.574 25.4466 144.574 27.9675Z" fill="white"/>
<path d="M126.917 19.635H129.235V18.2906C129.235 13.2856 131.855 11.1008 136.122 11.1008C136.828 11.1008 137.634 11.1681 138.44 11.3025V15.7376H136.994C134.878 15.7376 134.408 16.8468 134.408 18.2906V19.635H138.44V23.9357H134.408V36.4329H129.235V23.9357H126.917V19.635Z" fill="white"/>
<path d="M114.606 36.8699C111.045 36.8699 108.828 34.7875 108.828 31.4951C108.828 28.4045 111.011 26.4901 115.043 26.1876L119.746 25.8179V25.549C119.746 23.9021 118.737 23.0297 116.89 23.0297C114.707 23.0297 113.531 23.87 113.531 25.381H109.23C109.23 21.5844 112.354 19.0988 117.158 19.0988C121.961 19.0988 124.783 21.8197 124.783 26.6246V36.4344H120.215L119.879 34.2176C119.341 35.7622 117.124 36.8714 114.605 36.8714L114.606 36.8699ZM116.42 33.0397C118.401 33.0397 119.779 32.065 119.779 30.1507V29.2432L117.159 29.4785C114.909 29.6801 114.102 30.1843 114.102 31.2583C114.102 32.4683 114.842 33.0382 116.42 33.0382V33.0397Z" fill="white"/>
<path d="M104.017 36.4329H98.8441V23.9357H95.6526V19.635H98.8441V14.4283H104.017V19.635H107.209V23.9357H104.017V36.4329Z" fill="white"/>
<path d="M83.5052 36.8699C79.944 36.8699 77.7272 34.7875 77.7272 31.4951C77.7272 28.4045 79.9104 26.4901 83.9422 26.1876L88.6462 25.8179V25.549C88.6462 23.9021 87.6379 23.0297 85.7908 23.0297C83.6076 23.0297 82.4312 23.87 82.4312 25.381H78.1305C78.1305 21.5844 81.2548 19.0988 86.0581 19.0988C90.8615 19.0988 93.6833 21.8197 93.6833 26.6246V36.4344H89.1137L88.7776 34.2176C88.2398 35.7622 86.023 36.8714 83.5037 36.8714L83.5052 36.8699ZM85.3187 33.0397C87.3002 33.0397 88.6783 32.065 88.6783 30.1507V29.2432L86.0581 29.4785C83.8077 29.6801 83.0011 30.1843 83.0011 31.2583C83.0011 32.4683 83.7405 33.0382 85.3187 33.0382V33.0397Z" fill="white"/>
<path d="M63.9986 36.8699C59.1617 36.8699 56.0374 33.4431 56.0374 28.1356C56.0374 22.8281 59.2289 19.0988 64.3347 19.0988C66.4171 19.0988 68.4001 19.9391 69.3733 21.1812V11.1024H74.5464V36.433H69.6758L69.4069 34.2834C68.4994 35.828 66.4171 36.8699 63.9986 36.8699ZM65.2407 32.1323C67.6592 32.1323 69.3397 30.4533 69.3397 27.9324C69.3397 25.4116 67.6607 23.7326 65.2407 23.7326C62.8207 23.7326 61.2425 25.4452 61.2425 27.9324C61.2425 30.4196 62.7871 32.1323 65.2407 32.1323Z" fill="white"/>
<path d="M228.377 28.3024C228.377 26.6 228.702 25.1104 229.351 23.8336C230.023 22.5344 230.942 21.5264 232.107 20.8096C233.294 20.0704 234.671 19.7008 236.239 19.7008C238.121 19.7008 239.7 20.2048 240.977 21.2128C242.276 22.2208 243.083 23.5648 243.396 25.2448H241.078C240.764 24.1248 240.171 23.2624 239.297 22.6576C238.423 22.0528 237.415 21.7504 236.273 21.7504C235.198 21.7504 234.235 22.0192 233.383 22.5568C232.532 23.0944 231.86 23.856 231.367 24.8416C230.897 25.8048 230.662 26.9472 230.662 28.2688C230.662 29.568 230.886 30.6992 231.334 31.6624C231.782 32.6256 232.409 33.376 233.215 33.9136C234.044 34.4512 235.007 34.72 236.105 34.72C237.337 34.72 238.401 34.4176 239.297 33.8128C240.193 33.1856 240.798 32.3456 241.111 31.2928H243.463C243.217 32.3904 242.747 33.3536 242.052 34.1824C241.358 35.0112 240.495 35.6608 239.465 36.1312C238.457 36.5792 237.337 36.8032 236.105 36.8032C234.559 36.8032 233.204 36.456 232.039 35.7616C230.897 35.0448 230.001 34.048 229.351 32.7712C228.702 31.4944 228.377 30.0048 228.377 28.3024Z" fill="white"/>
<path d="M249.395 36.4H247.076V11.9056H249.395V36.4Z" fill="white"/>
<path d="M253.183 28.2688C253.183 26.5888 253.542 25.1104 254.258 23.8336C254.975 22.5568 255.961 21.5488 257.215 20.8096C258.47 20.0704 259.881 19.7008 261.449 19.7008C263.039 19.7008 264.45 20.0704 265.682 20.8096C266.937 21.5488 267.922 22.5568 268.639 23.8336C269.356 25.1104 269.714 26.5888 269.714 28.2688C269.714 29.904 269.356 31.3712 268.639 32.6704C267.922 33.9472 266.937 34.9552 265.682 35.6944C264.45 36.4336 263.039 36.8032 261.449 36.8032C259.881 36.8032 258.47 36.4336 257.215 35.6944C255.961 34.9552 254.975 33.9472 254.258 32.6704C253.542 31.3712 253.183 29.904 253.183 28.2688ZM255.535 28.2352C255.535 29.512 255.782 30.6432 256.274 31.6288C256.79 32.592 257.484 33.3536 258.358 33.9136C259.254 34.4512 260.284 34.72 261.449 34.72C262.614 34.72 263.633 34.4512 264.506 33.9136C265.402 33.3536 266.097 32.592 266.59 31.6288C267.105 30.6432 267.362 29.512 267.362 28.2352C267.362 26.9808 267.105 25.872 266.59 24.9088C266.097 23.9456 265.402 23.184 264.506 22.624C263.633 22.064 262.614 21.784 261.449 21.784C260.284 21.784 259.254 22.064 258.358 22.624C257.484 23.184 256.79 23.9456 256.274 24.9088C255.782 25.872 255.535 26.9808 255.535 28.2352Z" fill="white"/>
<path d="M284.453 20.1376H286.771V36.4H284.755L284.453 33.6448C284.005 34.5856 283.254 35.3472 282.202 35.9296C281.171 36.512 280.018 36.8032 278.741 36.8032C276.837 36.8032 275.37 36.1984 274.339 34.9888C273.331 33.7792 272.827 32.1888 272.827 30.2176V20.104H275.179V29.4784C275.179 31.4048 275.571 32.76 276.355 33.544C277.162 34.328 278.203 34.72 279.48 34.72C281.048 34.72 282.269 34.216 283.142 33.208C284.016 32.1776 284.453 30.6992 284.453 28.7728V20.1376Z" fill="white"/>
<path d="M297.656 36.8032C296.088 36.8032 294.744 36.4336 293.624 35.6944C292.504 34.9552 291.642 33.9472 291.037 32.6704C290.432 31.3712 290.13 29.9152 290.13 28.3024C290.13 26.6672 290.432 25.2 291.037 23.9008C291.642 22.6016 292.504 21.5824 293.624 20.8432C294.767 20.0816 296.144 19.7008 297.757 19.7008C299.101 19.7008 300.288 19.992 301.319 20.5744C302.372 21.1568 303.178 22.0416 303.738 23.2288V11.9056H306.056V36.4H304.04L303.805 33.0736C303.223 34.328 302.394 35.2688 301.319 35.896C300.244 36.5008 299.023 36.8032 297.656 36.8032ZM298.06 34.6864C299.269 34.6864 300.288 34.4176 301.117 33.88C301.946 33.32 302.584 32.5584 303.032 31.5952C303.48 30.6096 303.704 29.4896 303.704 28.2352C303.704 26.9808 303.48 25.872 303.032 24.9088C302.584 23.9456 301.946 23.184 301.117 22.624C300.288 22.064 299.269 21.784 298.06 21.784C296.895 21.784 295.887 22.064 295.036 22.624C294.207 23.184 293.568 23.9456 293.12 24.9088C292.695 25.872 292.482 26.9808 292.482 28.2352C292.482 29.4896 292.695 30.6096 293.12 31.5952C293.568 32.5584 294.207 33.32 295.036 33.88C295.887 34.4176 296.895 34.6864 298.06 34.6864Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg width="258" height="40" viewBox="0 0 258 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.28 37.1326H31.1245V33.8288H32.3289C33.0011 33.8288 33.4493 33.4087 33.4493 32.4564V27.3052C33.4493 25.1218 34.4296 23.9454 36.2489 23.3584C34.4296 22.7703 33.4493 21.5951 33.4493 19.4117V14.2605C33.4493 13.3082 33.0011 12.8894 32.3289 12.8894H31.1245V9.58553H33.28C35.6875 9.58553 37.3668 11.0688 37.3668 13.4215V18.8248C37.3668 20.7281 38.5151 21.6804 40.4745 21.6804V25.0403C38.5151 25.0403 37.3668 25.9926 37.3668 27.896V33.2992C37.3668 35.6507 35.6875 37.1339 33.28 37.1339V37.1326Z" fill="#1E302D"/>
<path d="M0 25.0377V21.6779C1.95937 21.6779 3.10775 20.7256 3.10775 18.8222V13.419C3.10775 11.0675 4.78704 9.58298 7.19456 9.58298H9.35V12.8868H8.1456C7.47338 12.8868 7.02523 13.3069 7.02523 14.258V19.4091C7.02523 21.5926 6.04491 22.769 4.22558 23.3559C6.04491 23.9441 7.02523 25.1192 7.02523 27.3027V32.4538C7.02523 33.4061 7.47338 33.8263 8.1456 33.8263H9.35V37.1301H7.19456C4.78704 37.1301 3.10775 35.6469 3.10775 33.2954V27.8921C3.10775 25.9888 1.95937 25.0365 0 25.0365V25.0377Z" fill="#1E302D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2381 0.063694L27.3142 7.13985L24.0142 10.4386L20.2368 6.66115L16.4594 10.4386L13.1606 7.13985L20.2368 0.063694H20.2381Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.238 9.75996L28.6714 18.1933L25.3726 21.492L20.238 16.3574L15.1034 21.492L11.8047 18.1933L20.238 9.75996Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2382 19.4563L30.0287 29.2468L26.7299 32.5468L20.2394 26.0562L13.7489 32.5468L10.4502 29.2468L20.2407 19.4563H20.2382Z" fill="#02C382"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9058 39.9132V31.0941H22.5718V39.9132H17.9058Z" fill="#1E302D"/>
<path d="M180.674 30.3608H176.363V19.9464H173.703V16.3625H176.363V12.0236H180.674V16.3625H183.333V19.9464H180.674V30.3608Z" fill="#1E302D"/>
<path d="M160.559 25.9366H164.646C164.702 26.9169 165.43 27.5038 166.745 27.5038C168.06 27.5038 168.705 27.0277 168.705 26.2994C168.705 25.8793 168.481 25.4311 167.444 25.2071L164.869 24.6469C162.293 24.0867 161.034 22.9676 161.034 20.5041C161.034 17.5924 163.526 15.9131 166.885 15.9131C170.245 15.9131 172.401 17.8164 172.401 20.6441H168.314C168.314 19.6918 167.726 19.0488 166.691 19.0488C165.71 19.0488 165.095 19.553 165.095 20.3093C165.095 20.9535 165.627 21.3176 166.607 21.5417L169.21 22.1019C171.506 22.606 172.766 23.6691 172.766 25.9098C172.766 28.9056 170.218 30.7249 166.579 30.7249C162.939 30.7249 160.559 28.7655 160.559 25.9378V25.9366Z" fill="#1E302D"/>
<path d="M151.959 30.7249C147.732 30.7249 144.708 27.6451 144.708 23.3343C144.708 19.0234 147.648 15.9156 151.875 15.9156C156.102 15.9156 159.014 18.7993 159.014 23.3623V24.4546L148.851 24.5107C149.103 26.358 150.14 27.2263 152.043 27.2263C153.666 27.2263 154.815 26.6381 155.123 25.631H159.07C158.565 28.7388 155.794 30.7262 151.959 30.7262V30.7249ZM151.903 19.4142C150.196 19.4142 149.243 20.1705 148.907 21.8498H154.731C154.731 20.3653 153.638 19.4142 151.903 19.4142Z" fill="#1E302D"/>
<path d="M143.694 16.3625V20.4213H142.323C139.859 20.4213 138.376 21.5137 138.376 24.2853V30.3608H134.065V16.3905H138.124L138.348 18.4619C138.936 17.0895 140.167 16.1664 142.044 16.1664C142.548 16.1664 143.107 16.2225 143.695 16.3625H143.694Z" fill="#1E302D"/>
<path d="M116.139 23.3343C116.139 18.9113 119.387 15.9436 123.866 15.9436C128.345 15.9436 131.565 18.9113 131.565 23.3343C131.565 27.7572 128.317 30.6969 123.866 30.6969C119.415 30.6969 116.139 27.7572 116.139 23.3343ZM120.478 23.3062C120.478 25.4337 121.849 26.8341 123.866 26.8341C125.883 26.8341 127.226 25.435 127.226 23.3062C127.226 21.1775 125.855 19.8064 123.866 19.8064C121.877 19.8064 120.478 21.2056 120.478 23.3062Z" fill="#1E302D"/>
<path d="M105.764 16.3625H107.696V15.2421C107.696 11.0713 109.879 9.2507 113.435 9.2507C114.023 9.2507 114.695 9.30672 115.366 9.41875V13.1147H114.162C112.399 13.1147 112.006 14.039 112.006 15.2421V16.3625H115.366V19.9464H112.006V30.3608H107.696V19.9464H105.764V16.3625Z" fill="#1E302D"/>
<path d="M95.505 30.7249C92.5373 30.7249 90.6899 28.9896 90.6899 26.246C90.6899 23.6704 92.5093 22.0751 95.8691 21.823L99.7879 21.5149V21.2909C99.7879 19.9184 98.9476 19.1914 97.4083 19.1914C95.589 19.1914 94.6087 19.8917 94.6087 21.1508H91.0248C91.0248 17.987 93.6284 15.9156 97.6311 15.9156C101.634 15.9156 103.985 18.1831 103.985 22.1872V30.362H100.179L99.8986 28.5147C99.4505 29.8019 97.6031 30.7262 95.5037 30.7262L95.505 30.7249ZM97.0162 27.5331C98.6675 27.5331 99.8159 26.7208 99.8159 25.1256V24.3693L97.6324 24.5654C95.7571 24.7335 95.0848 25.1536 95.0848 26.0486C95.0848 27.0569 95.7011 27.5318 97.0162 27.5318V27.5331Z" fill="#1E302D"/>
<path d="M86.6809 30.3608H82.37V19.9464H79.7104V16.3625H82.37V12.0236H86.6809V16.3625H89.3405V19.9464H86.6809V30.3608Z" fill="#1E302D"/>
<path d="M69.5875 30.7249C66.6198 30.7249 64.7725 28.9896 64.7725 26.246C64.7725 23.6704 66.5918 22.0751 69.9516 21.823L73.8716 21.5149V21.2909C73.8716 19.9184 73.0314 19.1914 71.4921 19.1914C69.6728 19.1914 68.6925 19.8917 68.6925 21.1508H65.1086C65.1086 17.987 67.7122 15.9156 71.7149 15.9156C75.7177 15.9156 78.0692 18.1831 78.0692 22.1872V30.362H74.2612L73.9811 28.5147C73.533 29.8019 71.6857 30.7262 69.5862 30.7262L69.5875 30.7249ZM71.0987 27.5331C72.75 27.5331 73.8984 26.7208 73.8984 25.1256V24.3693L71.7149 24.5654C69.8396 24.7335 69.1674 25.1536 69.1674 26.0486C69.1674 27.0569 69.7836 27.5318 71.0987 27.5318V27.5331Z" fill="#1E302D"/>
<path d="M53.3321 30.7249C49.3013 30.7249 46.6978 27.8692 46.6978 23.4463C46.6978 19.0234 49.3574 15.9156 53.6122 15.9156C55.3475 15.9156 57.0001 16.6159 57.8111 17.6509V9.25197H62.1219V30.3608H58.0631L57.8391 28.5695C57.0828 29.8566 55.3475 30.7249 53.3321 30.7249ZM54.3672 26.7769C56.3826 26.7769 57.7831 25.3777 57.7831 23.277C57.7831 21.1763 56.3839 19.7771 54.3672 19.7771C52.3505 19.7771 51.0354 21.2043 51.0354 23.277C51.0354 25.3497 52.3225 26.7769 54.3672 26.7769Z" fill="#1E302D"/>
<path d="M190.314 23.5854C190.314 22.1667 190.585 20.9254 191.126 19.8614C191.686 18.7787 192.451 17.9387 193.422 17.3414C194.411 16.7254 195.559 16.4174 196.866 16.4174C198.434 16.4174 199.75 16.8374 200.814 17.6774C201.897 18.5174 202.569 19.6374 202.83 21.0374H200.898C200.637 20.104 200.142 19.3854 199.414 18.8814C198.686 18.3774 197.846 18.1254 196.894 18.1254C195.998 18.1254 195.195 18.3494 194.486 18.7974C193.777 19.2454 193.217 19.88 192.806 20.7014C192.414 21.504 192.218 22.456 192.218 23.5574C192.218 24.64 192.405 25.5827 192.778 26.3854C193.151 27.188 193.674 27.8134 194.346 28.2614C195.037 28.7094 195.839 28.9334 196.754 28.9334C197.781 28.9334 198.667 28.6814 199.414 28.1774C200.161 27.6547 200.665 26.9547 200.926 26.0774H202.886C202.681 26.992 202.289 27.7947 201.71 28.4854C201.131 29.176 200.413 29.7174 199.554 30.1094C198.714 30.4827 197.781 30.6694 196.754 30.6694C195.466 30.6694 194.337 30.38 193.366 29.8014C192.414 29.204 191.667 28.3734 191.126 27.3094C190.585 26.2454 190.314 25.004 190.314 23.5854Z" fill="#1E302D"/>
<path d="M207.829 30.3334H205.897V9.92135H207.829V30.3334Z" fill="#1E302D"/>
<path d="M210.986 23.5574C210.986 22.1574 211.285 20.9254 211.882 19.8614C212.479 18.7974 213.301 17.9574 214.346 17.3414C215.391 16.7254 216.567 16.4174 217.874 16.4174C219.199 16.4174 220.375 16.7254 221.402 17.3414C222.447 17.9574 223.269 18.7974 223.866 19.8614C224.463 20.9254 224.762 22.1574 224.762 23.5574C224.762 24.92 224.463 26.1427 223.866 27.2254C223.269 28.2894 222.447 29.1294 221.402 29.7454C220.375 30.3614 219.199 30.6694 217.874 30.6694C216.567 30.6694 215.391 30.3614 214.346 29.7454C213.301 29.1294 212.479 28.2894 211.882 27.2254C211.285 26.1427 210.986 24.92 210.986 23.5574ZM212.946 23.5294C212.946 24.5934 213.151 25.536 213.562 26.3574C213.991 27.16 214.57 27.7947 215.298 28.2614C216.045 28.7094 216.903 28.9334 217.874 28.9334C218.845 28.9334 219.694 28.7094 220.422 28.2614C221.169 27.7947 221.747 27.16 222.158 26.3574C222.587 25.536 222.802 24.5934 222.802 23.5294C222.802 22.484 222.587 21.56 222.158 20.7574C221.747 19.9547 221.169 19.32 220.422 18.8534C219.694 18.3867 218.845 18.1534 217.874 18.1534C216.903 18.1534 216.045 18.3867 215.298 18.8534C214.57 19.32 213.991 19.9547 213.562 20.7574C213.151 21.56 212.946 22.484 212.946 23.5294Z" fill="#1E302D"/>
<path d="M237.044 16.7814H238.976V30.3334H237.296L237.044 28.0374C236.67 28.8214 236.045 29.456 235.168 29.9414C234.309 30.4267 233.348 30.6694 232.284 30.6694C230.697 30.6694 229.474 30.1654 228.616 29.1574C227.776 28.1494 227.356 26.824 227.356 25.1814V16.7534H229.316V24.5654C229.316 26.1707 229.642 27.3 230.296 27.9534C230.968 28.6067 231.836 28.9334 232.9 28.9334C234.206 28.9334 235.224 28.5134 235.952 27.6734C236.68 26.8147 237.044 25.5827 237.044 23.9774V16.7814Z" fill="#1E302D"/>
<path d="M248.047 30.6694C246.74 30.6694 245.62 30.3614 244.687 29.7454C243.754 29.1294 243.035 28.2894 242.531 27.2254C242.027 26.1427 241.775 24.9294 241.775 23.5854C241.775 22.2227 242.027 21 242.531 19.9174C243.035 18.8347 243.754 17.9854 244.687 17.3694C245.639 16.7347 246.787 16.4174 248.131 16.4174C249.251 16.4174 250.24 16.66 251.099 17.1454C251.976 17.6307 252.648 18.368 253.115 19.3574V9.92135H255.047V30.3334H253.367L253.171 27.5614C252.686 28.6067 251.995 29.3907 251.099 29.9134C250.203 30.4174 249.186 30.6694 248.047 30.6694ZM248.383 28.9054C249.391 28.9054 250.24 28.6814 250.931 28.2334C251.622 27.7667 252.154 27.132 252.527 26.3294C252.9 25.508 253.087 24.5747 253.087 23.5294C253.087 22.484 252.9 21.56 252.527 20.7574C252.154 19.9547 251.622 19.32 250.931 18.8534C250.24 18.3867 249.391 18.1534 248.383 18.1534C247.412 18.1534 246.572 18.3867 245.863 18.8534C245.172 19.32 244.64 19.9547 244.267 20.7574C243.912 21.56 243.735 22.484 243.735 23.5294C243.735 24.5747 243.912 25.508 244.267 26.3294C244.64 27.132 245.172 27.7667 245.863 28.2334C246.572 28.6814 247.412 28.9054 248.383 28.9054Z" fill="#1E302D"/>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

+7 -7
View File
@@ -1,21 +1,21 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-non-null-assertion": 377,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
"trek/no-swallowed-catch": 129,
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+5 -5
View File
@@ -742,11 +742,8 @@
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Map: useMapSettingsForm",
@@ -983,8 +980,11 @@
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
-4
View File
@@ -899,7 +899,6 @@
"src/components/Roadtrip/useVehicleRange.test.ts",
"src/components/Roadtrip/useVehicleRange.ts",
"src/components/Roadtrip/vehicleRange.ts",
"src/components/Settings/AboutTab.tsx",
"src/components/Settings/AccountTab.test.tsx",
"src/components/Settings/AccountTab.tsx",
"src/components/Settings/AirTrailConnectionSection.test.tsx",
@@ -991,9 +990,6 @@
"src/components/Studio/useBookPresence.ts",
"src/components/Studio/useBookStore.ts",
"src/components/Studio/useSpreadInteraction.ts",
"src/components/SystemNotices/ReleaseNoticeModal.test.tsx",
"src/components/SystemNotices/ReleaseNoticeModal.tsx",
"src/components/SystemNotices/ReleaseNoticeVisuals.tsx",
"src/components/SystemNotices/SystemNoticeBanner.extra.test.tsx",
"src/components/SystemNotices/SystemNoticeBanner.test.tsx",
"src/components/SystemNotices/SystemNoticeBanner.tsx",
+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,
+7 -9
View File
@@ -2,27 +2,25 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"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": 1211,
"src/pages/atlas/useAtlas.ts": 1210,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918
+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,
+1 -1
View File
@@ -35,7 +35,7 @@
"components/Roadtrip/dayColors.ts": 8,
"components/Roadtrip/nightBookend.ts": 1,
"components/Roadtrip/stopKinds.ts": 1,
"components/Settings/AboutTab.tsx": 3,
"components/Settings/AboutTab.tsx": 5,
"components/Settings/AccountTab.tsx": 2,
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
+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>
}
/>
+14
View File
@@ -0,0 +1,14 @@
import type { PlaceContribDecisionRequest, PlaceContribLogQuery } from '@trek/shared';
import apiClient from './client';
/** /api/admin/places-contribute: sharing place details with the TREK Places API. Admin only. */
export const placeContribApi = {
state: (signal?: AbortSignal): Promise<unknown> =>
apiClient.get('/admin/places-contribute', { signal }).then((r) => r.data),
decide: (body: PlaceContribDecisionRequest): Promise<unknown> =>
apiClient.put('/admin/places-contribute/decision', body).then((r) => r.data),
log: (query: Partial<PlaceContribLogQuery>, signal?: AbortSignal): Promise<unknown> =>
apiClient.get('/admin/places-contribute/log', { params: query, signal }).then((r) => r.data),
withdraw: (): Promise<unknown> =>
apiClient.post('/admin/places-contribute/withdraw', { confirm: true }).then((r) => r.data),
};
+1
View File
@@ -22,6 +22,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
az: 'Həddindən çox cəhd edildi. Bir az sonra yenidən cəhd edin.',
he: 'יותר מדי ניסיונות. נסו שוב מאוחר יותר.',
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+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)}
@@ -0,0 +1,80 @@
// FE-ADMIN-CONTRIB-LOG-001 to FE-ADMIN-CONTRIB-LOG-004
import userEvent from '@testing-library/user-event';
import type { PlaceContribLogEntry } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import PlaceContribLog from './PlaceContribLog';
vi.mock('../../i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('../../i18n')>()),
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
}));
const ENTRY: PlaceContribLogEntry = {
id: 1,
day: '2026-10-29',
key: 'osm:n123',
placeName: 'Café Luitpold',
field: 'phone',
valueSummary: '+49 89 242 8750',
result: 'accepted',
reason: null,
};
function serve(entries: PlaceContribLogEntry[], total = entries.length) {
const queries: URLSearchParams[] = [];
server.use(
http.get('/api/admin/places-contribute/log', ({ request }) => {
queries.push(new URL(request.url).searchParams);
return HttpResponse.json({
entries,
total,
page: Number(new URL(request.url).searchParams.get('page') ?? 1),
perPage: 20,
});
})
);
return queries;
}
describe('PlaceContribLog', () => {
it('FE-ADMIN-CONTRIB-LOG-001: lists the place, the value, the field and the result', async () => {
serve([ENTRY]);
render(<PlaceContribLog />);
expect(await screen.findByText('Café Luitpold')).toBeInTheDocument();
expect(screen.getByText('+49 89 242 8750')).toBeInTheDocument();
expect(screen.getByText('admin.contribute.field.phone')).toBeInTheDocument();
expect(screen.getByText('admin.contribute.result.accepted')).toBeInTheDocument();
});
it('FE-ADMIN-CONTRIB-LOG-002: an empty log says nothing was sent', async () => {
serve([]);
render(<PlaceContribLog />);
expect(await screen.findByText('admin.contribute.log.empty')).toBeInTheDocument();
});
it('FE-ADMIN-CONTRIB-LOG-003: pages through a long log', async () => {
const queries = serve([ENTRY], 45);
render(<PlaceContribLog />);
await userEvent.click(await screen.findByRole('button', { name: /common\.showMore/ }));
expect(await screen.findByText('1 / 3')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.log.next' }));
await waitFor(() => expect(queries[queries.length - 1]?.get('page')).toBe('2'));
expect(queries[queries.length - 1]?.get('perPage')).toBe('20');
});
it('FE-ADMIN-CONTRIB-LOG-004: shows the newest five until the list is opened', async () => {
serve(Array.from({ length: 8 }, (_, i) => ({ ...ENTRY, id: i + 1, placeName: `Place ${i + 1}` })));
render(<PlaceContribLog />);
expect(await screen.findByText('Place 5')).toBeInTheDocument();
expect(screen.queryByText('Place 6')).not.toBeInTheDocument();
const toggle = screen.getByRole('button', { name: 'common.showMore (3)' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(toggle);
expect(screen.getByText('Place 8')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'common.showLess' }));
expect(screen.queryByText('Place 8')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,187 @@
import {
PLACE_CONTRIB_FIELDS,
type PlaceContribField,
type PlaceContribLogResponse,
type PlaceContribLogResult,
} from '@trek/shared';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { placeContribRepo } from '../../repo/placeContribRepo';
import { PillSelect } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import { fieldLabel } from './placeContribModel';
const PER_PAGE = 20;
/** Rows shown before the list is opened; a busy log should not push the page down. */
const FOLDED = 5;
const RESULTS: PlaceContribLogResult[] = ['accepted', 'duplicate', 'rejected', 'withheld', 'retracted'];
const RESULT_LOOK: Record<PlaceContribLogResult, string> = {
accepted: 'bg-success-soft text-success',
duplicate: 'bg-surface-tertiary text-content-muted',
rejected: 'bg-danger-soft text-danger',
withheld: 'bg-surface-tertiary text-content-muted',
retracted: 'bg-warning-soft text-warning',
};
/**
* What this installation sent, newest first: the place, the field, a short
* form of the value and what the service made of it. Read only while the fold
* is open, a page at a time, and only the newest FOLDED rows until opened.
*/
export default function PlaceContribLog() {
const { t, locale } = useTranslation();
const [page, setPage] = useState(1);
const [result, setResult] = useState<PlaceContribLogResult | 'all'>('all');
const [field, setField] = useState<PlaceContribField | 'all'>('all');
const [data, setData] = useState<PlaceContribLogResponse | null>(null);
const [open, setOpen] = useState(false);
// The query that failed to load, so a new page or filter clears the message by itself.
const query = `${page}|${result}|${field}`;
const [failedQuery, setFailedQuery] = useState<string | null>(null);
const failed = failedQuery === query;
useEffect(() => {
const controller = new AbortController();
placeContribRepo
.log(
{
page,
perPage: PER_PAGE,
...(result === 'all' ? {} : { result }),
...(field === 'all' ? {} : { field }),
},
controller.signal
)
.then(setData, () => {
if (!controller.signal.aborted) setFailedQuery(`${page}|${result}|${field}`);
});
return () => controller.abort();
}, [page, result, field]);
const pages = data ? Math.max(1, Math.ceil(data.total / PER_PAGE)) : 1;
const foldable = !!data && (data.entries.length > FOLDED || pages > 1);
const rows = data ? (open || !foldable ? data.entries : data.entries.slice(0, FOLDED)) : [];
const day = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' });
return (
<div className="flex flex-col gap-2.5">
<div className="flex flex-wrap gap-2">
<PillSelect<PlaceContribLogResult | 'all'>
quiet
label={t('admin.contribute.log.result')}
value={result}
onChange={(v) => {
setResult(v);
setPage(1);
}}
options={[
{ value: 'all', label: t('admin.contribute.log.allResults') },
...RESULTS.map((r) => ({ value: r, label: t(`admin.contribute.result.${r}`) })),
]}
/>
<PillSelect<PlaceContribField | 'all'>
quiet
label={t('admin.contribute.log.field')}
value={field}
onChange={(v) => {
setField(v);
setPage(1);
}}
options={[
{ value: 'all', label: t('admin.contribute.log.allFields') },
...PLACE_CONTRIB_FIELDS.map((f) => ({ value: f, label: fieldLabel(t, f) })),
]}
/>
</div>
{failed && (
<p className="m-0 text-danger" style={fs(12, 'body')}>
{t('admin.contribute.log.failed')}
</p>
)}
{data && data.entries.length === 0 && (
<p className="m-0 py-3 text-center text-content-faint" style={fs(12, 'body')}>
{t('admin.contribute.log.empty')}
</p>
)}
{data && data.entries.length > 0 && (
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
{rows.map((e) => (
<li key={e.id} className="flex flex-wrap items-center gap-x-2.5 gap-y-1 px-3 py-2">
<span className="w-12 flex-none tabular-nums text-content-faint" style={fs(11.5)}>
{day.format(new Date(`${e.day}T12:00:00`))}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-content" style={fs(12.5, 'body')}>
{e.placeName}
</span>
{e.valueSummary && (
<span className="block truncate text-content-muted" style={fs(11.5)}>
{e.valueSummary}
</span>
)}
</span>
<span
className="inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 py-[2px] text-content-secondary"
style={fs(11)}
>
{fieldLabel(t, e.field)}
</span>
<span
className={`inline-flex flex-none items-center rounded-full px-2 py-[2px] font-semibold ${RESULT_LOOK[e.result]}`}
style={fs(11)}
title={e.reason ?? undefined}
>
{t(`admin.contribute.result.${e.result}`)}
</span>
</li>
))}
</ul>
)}
{foldable && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1 self-start font-medium text-content-muted hover:text-content"
style={fs(12, 'body')}
>
<ChevronDown
size={14}
aria-hidden="true"
className={open ? 'rotate-180 transition-transform' : 'transition-transform'}
/>
{open ? t('common.showLess') : `${t('common.showMore')} (${(data?.total ?? 0) - FOLDED})`}
</button>
)}
{data && open && pages > 1 && (
<div className="flex items-center justify-end gap-2 text-content-muted" style={fs(12, 'body')}>
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
aria-label={t('admin.contribute.log.previous')}
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
>
<ChevronLeft size={14} aria-hidden="true" />
</button>
<span className="tabular-nums">
{page} / {pages}
</span>
<button
type="button"
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
aria-label={t('admin.contribute.log.next')}
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
>
<ChevronRight size={14} aria-hidden="true" />
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,223 @@
// 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';
import { describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import PlaceContribPanel from './PlaceContribPanel';
// The keys themselves, so a test fails on a missing or renamed key rather than on wording.
vi.mock('../../i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('../../i18n')>()),
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
}));
const ON = { website: true, phone: true, email: true, opening_hours: true, description: true, image: true };
const UNDECIDED: PlaceContribState = {
termsVersion: 1,
ceiling: 'admin',
eligible: true,
ineligibleReason: null,
consent: { enabled: false, fields: ON, termsVersion: null, decision: null, decidedBy: null, decidedAt: null },
showPrompt: true,
snoozedUntil: null,
active: false,
counts: { accepted: 0, duplicate: 0, rejected: 0, withheld: 0, retracted: 0 },
lastSuccessDay: null,
queue: { size: 0, cap: 5000, full: false },
pauseReason: null,
registrationOpen: false,
};
const AGREED: PlaceContribState = {
...UNDECIDED,
showPrompt: false,
active: true,
consent: {
enabled: true,
fields: ON,
termsVersion: 1,
decision: 'agreed',
decidedBy: 1,
decidedAt: '2026-10-30T10:00:00Z',
},
counts: { accepted: 42, duplicate: 3, rejected: 1, withheld: 5, retracted: 0 },
lastSuccessDay: '2026-10-29',
queue: { size: 7, cap: 5000, full: false },
};
/** Serves `state`, records every decision and answers it with `answer`. */
function serve(
state: PlaceContribState,
answer: (body: PlaceContribDecisionRequest) => PlaceContribState = () => state
) {
const decisions: PlaceContribDecisionRequest[] = [];
server.use(
http.get('/api/admin/places-contribute', () => HttpResponse.json(state)),
http.put('/api/admin/places-contribute/decision', async ({ request }) => {
const body = (await request.json()) as PlaceContribDecisionRequest;
decisions.push(body);
return HttpResponse.json(answer(body));
}),
http.get('/api/admin/places-contribute/log', () =>
HttpResponse.json({ entries: [], total: 0, page: 1, perPage: 20 })
)
);
return decisions;
}
describe('PlaceContribPanel', () => {
it('FE-ADMIN-CONTRIB-001: an undecided admin gets the prompt with every field switched on', async () => {
serve(UNDECIDED);
render(<PlaceContribPanel />);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('admin.contribute.prompt.title')).toBeInTheDocument();
for (const field of Object.keys(ON)) {
expect(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` })).toHaveAttribute(
'aria-pressed',
'true'
);
}
expect(within(dialog).getByText('admin.contribute.never.notes')).toBeInTheDocument();
expect(within(dialog).getByText('admin.contribute.never.providers')).toBeInTheDocument();
});
it('FE-ADMIN-CONTRIB-002: nothing is decided until a button is pressed, and Agree sends the switches it shows', async () => {
const decisions = serve(UNDECIDED, () => AGREED);
render(<PlaceContribPanel />);
const dialog = await screen.findByRole('dialog');
expect(decisions).toHaveLength(0);
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.field.image' }));
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' }));
await waitFor(() => expect(decisions).toHaveLength(1));
expect(decisions[0]).toEqual({ action: 'agree', termsVersion: 1, fields: { ...ON, image: false } });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(screen.getByText('admin.contribute.status.active')).toBeInTheDocument();
});
it('FE-ADMIN-CONTRIB-003: Agree is unavailable with every field off', async () => {
serve(UNDECIDED);
render(<PlaceContribPanel />);
const dialog = await screen.findByRole('dialog');
for (const field of Object.keys(ON)) {
await userEvent.click(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` }));
}
expect(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' })).toBeDisabled();
});
it('FE-ADMIN-CONTRIB-004: "not now" snoozes and "no" declines for the current terms', async () => {
const decisions = serve(UNDECIDED, () => ({ ...UNDECIDED, showPrompt: false }));
const { unmount } = render(<PlaceContribPanel />);
await userEvent.click(
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.snooze' })
);
await waitFor(() => expect(decisions).toEqual([{ action: 'snooze' }]));
unmount();
render(<PlaceContribPanel />);
await userEvent.click(
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.decline' })
);
await waitFor(() => expect(decisions[1]).toEqual({ action: 'decline', termsVersion: 1 }));
});
it('FE-ADMIN-CONTRIB-005: closing the prompt decides nothing', async () => {
const decisions = serve(UNDECIDED);
render(<PlaceContribPanel />);
await screen.findByRole('dialog');
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(decisions).toHaveLength(0);
});
it('FE-ADMIN-CONTRIB-006: an agreement shows its figures and a switch per field', async () => {
serve(AGREED);
render(<PlaceContribPanel />);
expect(await screen.findByText('admin.contribute.status.active')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'admin.contribute.field.phone' })).toHaveAttribute(
'aria-pressed',
'true'
);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-ADMIN-CONTRIB-007: switching a field off sends the new switches', async () => {
const decisions = serve(AGREED);
render(<PlaceContribPanel />);
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.field.description' }));
await waitFor(() => expect(decisions).toEqual([{ action: 'fields', fields: { ...ON, description: false } }]));
});
it('FE-ADMIN-CONTRIB-008: the master switch asks before it stops sharing', async () => {
const decisions = serve(AGREED, () => ({
...AGREED,
active: false,
consent: { ...AGREED.consent, enabled: false, decision: 'off' },
}));
render(<PlaceContribPanel />);
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.title' }));
expect(decisions).toHaveLength(0);
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.offConfirm' }));
await waitFor(() => expect(decisions).toEqual([{ action: 'off' }]));
});
it('FE-ADMIN-CONTRIB-009: an installation that cannot share says why and has no prompt', async () => {
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'custom_places_url', showPrompt: false });
render(<PlaceContribPanel />);
expect(await screen.findByText('admin.contribute.ineligible.custom_places_url')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'admin.contribute.title' })).toBeDisabled();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-ADMIN-CONTRIB-010: open registration is named in the prompt', async () => {
serve({ ...UNDECIDED, registrationOpen: true });
render(<PlaceContribPanel />);
expect(
await within(await screen.findByRole('dialog')).findByText('admin.contribute.prompt.openRegistration')
).toBeInTheDocument();
});
it('FE-ADMIN-CONTRIB-011: asks again while the key is still being fetched, and the prompt follows', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
let reads = 0;
server.use(
http.get('/api/admin/places-contribute', () => {
reads += 1;
return HttpResponse.json(
reads === 1 ? { ...UNDECIDED, eligible: false, ineligibleReason: 'no_key', showPrompt: false } : UNDECIDED
);
})
);
render(<PlaceContribPanel />);
expect(await screen.findByText('admin.contribute.ineligible.no_key')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(3_000);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(reads).toBe(2);
} finally {
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();
});
});
@@ -0,0 +1,209 @@
import type { PlaceContribField, PlaceContribState } from '@trek/shared';
import { ChevronRight, HeartHandshake, Undo2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import type { TranslationFn } from '../../types';
import { getApiErrorMessage } from '../../utils/apiError';
import { SettingsCard, StatusPill } from '../Settings/settingsKit';
import ToggleSwitch from '../Settings/ToggleSwitch';
import ConfirmDialog from '../shared/ConfirmDialog';
import { fs } from '../shared/DialogShell';
import { useToast } from '../shared/Toast';
import { FigureBadge } from '../Tours/paddle/FigureBadge';
import PlaceContribLog from './PlaceContribLog';
import {
contribNote,
contribStatus,
fieldEntries,
fieldLabel,
refusalReason,
type ContribStatus,
} from './placeContribModel';
import PlaceContribPrompt from './PlaceContribPrompt';
import { usePlaceContrib } from './usePlaceContrib';
const PILL_TONE: Record<ContribStatus, 'success' | 'warning' | 'neutral'> = {
active: 'success',
paused: 'warning',
off: 'neutral',
unavailable: 'neutral',
};
function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
const { locale } = useTranslation();
const number = new Intl.NumberFormat(locale);
const last = state.lastSuccessDay
? new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(
new Date(`${state.lastSuccessDay}T12:00:00`)
)
: t('admin.contribute.never');
return (
<div className="flex flex-wrap gap-1.5">
<FigureBadge
label={t('admin.contribute.figure.accepted')}
value={number.format(state.counts.accepted)}
info={t('admin.contribute.figure.acceptedInfo')}
/>
<FigureBadge
label={t('admin.contribute.figure.queue')}
value={number.format(state.queue.size)}
tone={state.queue.full ? 'warning' : 'neutral'}
info={state.queue.full ? t('admin.contribute.figure.queueFull') : undefined}
/>
<FigureBadge label={t('admin.contribute.figure.last')} value={last} />
</div>
);
}
/**
* Sharing place details with the TREK Places API, inside the TREK API card:
* one switch for the installation, one per field, the transmission log and the
* 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.
*/
/**
* 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();
const { state } = contrib;
const [prompting, setPrompting] = useState<boolean | null>(null);
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.
const showPrompt = prompting ?? state.showPrompt;
const fail = (err: unknown) => {
const reason = refusalReason(err);
toast.error(
reason === 'terms_outdated' || reason === 'no_field'
? t(`admin.contribute.refused.${reason}`)
: getApiErrorMessage(err, t('common.error'))
);
};
const toggleField = (field: PlaceContribField) => {
const fields = { ...state.consent.fields, [field]: !state.consent.fields[field] };
if (!Object.values(fields).some(Boolean)) {
setConfirm('off');
return;
}
contrib.decide({ action: 'fields', fields }).catch(fail);
};
const withdraw = () => {
setConfirm(null);
contrib.withdraw().then((r) => {
const total = Object.values(r.withdrawn).reduce((a, b) => a + b, 0);
toast.success(t('admin.contribute.withdrawn', { total }));
}, fail);
};
return (
<SettingsCard
flush
icon={HeartHandshake}
title={t('admin.contribute.title')}
badge={<StatusPill tone={PILL_TONE[status]}>{t(`admin.contribute.status.${status}`)}</StatusPill>}
hint={contribNote(t, state)}
action={
<ToggleSwitch
on={agreed && state.consent.enabled}
label={t('admin.contribute.title')}
disabled={!state.eligible || contrib.busy}
onToggle={() => (agreed && state.consent.enabled ? setConfirm('off') : setPrompting(true))}
/>
}
bodyClassName="flex flex-col gap-3 p-4"
>
{agreed && state.consent.enabled && (
<>
<Figures state={state} t={t} />
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
{fieldEntries(state.consent.fields).map(([field, on]) => (
<li
key={field}
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-1.5"
>
<span className={on ? 'text-content' : 'text-content-faint'} style={fs(12.5, 'body')}>
{fieldLabel(t, field)}
</span>
<ToggleSwitch
on={on}
label={fieldLabel(t, field)}
disabled={contrib.busy}
onToggle={() => toggleField(field)}
/>
</li>
))}
</ul>
</>
)}
{(agreed || state.consent.decision === 'off') && (
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The admin page's fold head (pages/admin/FoldSummary), which a component cannot import. */}
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
<ChevronRight
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-open:rotate-90"
aria-hidden="true"
/>
<span className="font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.contribute.log.title')}
</span>
</summary>
<div className="flex flex-col gap-3 border-t border-edge-faint p-3">
<PlaceContribLog />
<button
type="button"
disabled={contrib.busy}
onClick={() => setConfirm('withdraw')}
className="inline-flex items-center gap-1.5 self-start rounded-lg border border-edge-faint bg-surface-card px-3 py-1.5 font-medium text-danger hover:bg-danger-soft disabled:opacity-40"
style={fs(12.5, 'body')}
>
<Undo2 size={14} aria-hidden="true" />
{t('admin.contribute.withdraw')}
</button>
</div>
</details>
)}
{showPrompt && state.eligible && (
<PlaceContribPrompt state={state} contrib={contrib} onClose={() => setPrompting(false)} />
)}
<ConfirmDialog
isOpen={confirm === 'off'}
onClose={() => setConfirm(null)}
onConfirm={() => {
setConfirm(null);
contrib.decide({ action: 'off' }).catch(fail);
}}
title={t('admin.contribute.offTitle')}
message={t('admin.contribute.offMessage')}
confirmLabel={t('admin.contribute.offConfirm')}
/>
<ConfirmDialog
isOpen={confirm === 'withdraw'}
onClose={() => setConfirm(null)}
onConfirm={withdraw}
title={t('admin.contribute.withdrawTitle')}
message={t('admin.contribute.withdrawMessage')}
confirmLabel={t('admin.contribute.withdraw')}
danger
/>
</SettingsCard>
);
}
@@ -0,0 +1,198 @@
import type { PlaceContribFieldSwitches, PlaceContribState } from '@trek/shared';
import { Check, HeartHandshake, ShieldCheck, TriangleAlert, X } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../utils/apiError';
import ToggleSwitch from '../Settings/ToggleSwitch';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { useToast } from '../shared/Toast';
import { ALL_FIELDS_ON, fieldEntries, fieldLabel, refusalReason } from './placeContribModel';
import type { usePlaceContrib } from './usePlaceContrib';
const NEVER = ['notes', 'files', 'trips', 'people', 'providers'] as const;
/**
* The question an admin gets once: share what this installation's users type
* about places with every other TREK. Asked warmly and with every field already
* on, because saying yes is the point of asking; but nothing leaves before the
* admin presses Agree, and "not now" and "no" are as easy to reach.
*
* Closing the dialog decides nothing: it comes back on the next visit until one
* of the three buttons is pressed.
*/
export default function PlaceContribPrompt({
state,
contrib,
onClose,
}: {
state: PlaceContribState;
contrib: ReturnType<typeof usePlaceContrib>;
onClose: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [fields, setFields] = useState<PlaceContribFieldSwitches>(() =>
state.consent.decision === 'agreed' || state.consent.decision === 'off' ? state.consent.fields : ALL_FIELDS_ON
);
const none = !Object.values(fields).some(Boolean);
const run = async (body: Parameters<typeof contrib.decide>[0], done?: string) => {
try {
await contrib.decide(body);
if (done) toast.success(done);
onClose();
} catch (err) {
const reason = refusalReason(err);
toast.error(
reason === 'terms_outdated' || reason === 'no_field'
? t(`admin.contribute.refused.${reason}`)
: getApiErrorMessage(err, t('common.error'))
);
}
};
return (
<DialogShell
onClose={onClose}
labelledBy="place-contrib-prompt-title"
width="detail"
blocked={contrib.busy}
header={
<DialogHeader
tile={
<DialogTile>
<HeartHandshake size={22} className="text-content" aria-hidden="true" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId="place-contrib-prompt-title"
eyebrow="TREK Places API"
title={t('admin.contribute.prompt.title')}
onClose={onClose}
/>
}
footer={
<DialogFooter>
<DialogButton
disabled={contrib.busy}
onClick={() => void run({ action: 'decline', termsVersion: state.termsVersion })}
>
{t('admin.contribute.prompt.decline')}
</DialogButton>
<FooterSpacer />
<DialogButton disabled={contrib.busy} onClick={() => void run({ action: 'snooze' })}>
{t('admin.contribute.prompt.snooze')}
</DialogButton>
<DialogButton
variant="primary"
autoFocus
disabled={contrib.busy || none}
icon={<HeartHandshake size={15} aria-hidden="true" />}
onClick={() =>
void run(
{ action: 'agree', termsVersion: state.termsVersion, fields },
t('admin.contribute.prompt.thanks')
)
}
>
{t('admin.contribute.prompt.agree')}
</DialogButton>
</DialogFooter>
}
>
<div className="flex flex-col gap-2">
<p className="m-0 leading-relaxed text-content" style={fs(14, 'body')}>
{t('admin.contribute.prompt.lead')}
</p>
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{t('admin.contribute.prompt.why')}
</p>
</div>
<section className="flex flex-col gap-2" aria-labelledby="place-contrib-prompt-fields">
<h3 id="place-contrib-prompt-fields" className="m-0 font-semibold text-content" style={fs(13, 'body')}>
{t('admin.contribute.prompt.whatLabel')}
</h3>
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
{fieldEntries(fields).map(([field, on]) => (
<li
key={field}
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2"
>
<span className="flex min-w-0 items-center gap-2 text-content" style={fs(13, 'body')}>
<Check
size={14}
strokeWidth={2.4}
className={on ? 'text-success' : 'text-content-faint opacity-40'}
aria-hidden="true"
/>
<span className="truncate">{fieldLabel(t, field)}</span>
</span>
<ToggleSwitch
on={on}
label={fieldLabel(t, field)}
onToggle={() => setFields((f) => ({ ...f, [field]: !f[field] }))}
/>
</li>
))}
</ul>
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>
{t('admin.contribute.prompt.imageNote')}
</p>
</section>
<section
className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary p-3"
aria-labelledby="place-contrib-prompt-never"
>
<h3
id="place-contrib-prompt-never"
className="m-0 flex items-center gap-1.5 font-semibold text-content"
style={fs(13, 'body')}
>
<ShieldCheck size={15} className="text-success" aria-hidden="true" />
{t('admin.contribute.prompt.neverLabel')}
</h3>
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
{NEVER.map((item) => (
<li
key={item}
className="inline-flex items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2.5 py-[3px] font-medium text-content-secondary"
style={fs(11.5)}
>
<X size={12} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />
{t(`admin.contribute.never.${item}`)}
</li>
))}
</ul>
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
{t('admin.contribute.prompt.how')} {t('admin.contribute.prompt.past')}
</p>
</section>
{state.registrationOpen && (
<p
className="m-0 flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning"
style={fs(12, 'body')}
role="note"
>
<TriangleAlert size={15} className="mt-[1px] flex-none" aria-hidden="true" />
{t('admin.contribute.prompt.openRegistration')}
</p>
)}
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('admin.contribute.prompt.footnote')}
</p>
</DialogShell>
);
}
@@ -1,11 +1,12 @@
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react';
import { MapPin, RefreshCw, Route, Search, Signal, type LucideIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import type { TranslationFn } from '../../types';
import { getApiErrorMessage } from '../../utils/apiError';
import { formatClockTime } from '../../utils/formatters';
import { SettingsCard } from '../Settings/settingsKit';
import ConfirmDialog from '../shared/ConfirmDialog';
import { useToast } from '../shared/Toast';
import { FigureBadge } from '../Tours/paddle/FigureBadge';
@@ -121,7 +122,7 @@ function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, faile
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
const { t } = useTranslation();
const toast = useToast();
const panel = useRef<HTMLElement>(null);
const panel = useRef<HTMLDivElement>(null);
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
const [confirming, setConfirming] = useState(false);
const resetAt = useResetTime(status);
@@ -140,57 +141,66 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
return (
<section
ref={panel}
aria-label={t('admin.trekApi.quota.title')}
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
{status?.keySource === 'env' && (
<FigureBadge
label={t('admin.trekApi.quota.key')}
value="TREK_SERVICES_KEY"
info={t('admin.trekApi.quota.envKey')}
<div ref={panel}>
<SettingsCard
flush
icon={Signal}
title={t('admin.trekApi.quota.title')}
badge={
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2 py-[2px] text-caption font-semibold text-content shadow-sm">
<span
aria-hidden="true"
className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`}
/>
)}
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
</div>
</div>
{status ? t(`admin.trekApi.quota.state.${status.state}`) : '…'}
</span>
}
action={
<>
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
{status?.keySource === 'env' && (
<FigureBadge
label={t('admin.trekApi.quota.key')}
value="TREK_SERVICES_KEY"
info={t('admin.trekApi.quota.envKey')}
/>
)}
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
</>
}
>
{status && status.services.length > 0 && (
<ul
aria-label={t('admin.trekApi.quota.title')}
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
>
{status.services.map((allowance) => (
<ServiceTile key={allowance.service} allowance={allowance} />
))}
</ul>
)}
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
{status && status.services.length > 0 && (
<ul
aria-label={t('admin.trekApi.quota.title')}
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
>
{status.services.map((allowance) => (
<ServiceTile key={allowance.service} allowance={allowance} />
))}
</ul>
)}
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
{canRegenerate && (
<button
type="button"
onClick={() => setConfirming(true)}
disabled={regenerating}
className="inline-flex items-center gap-1.5 self-start rounded-lg text-caption font-medium text-content-secondary hover:text-content disabled:opacity-50"
>
<RefreshCw
size={13}
strokeWidth={2}
className={regenerating ? 'motion-safe:animate-spin' : ''}
aria-hidden="true"
/>
{t('admin.trekApi.quota.regenerate')}
</button>
)}
{/* The one action, on a rule of its own below the figures. */}
{canRegenerate && (
<div className="-mx-4 -mb-4 flex items-center justify-end border-t border-edge-faint px-4 py-2.5">
<button
type="button"
onClick={() => setConfirming(true)}
disabled={regenerating}
className="inline-flex items-center gap-1.5 rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm hover:bg-surface-hover hover:text-content disabled:opacity-50"
>
<RefreshCw
size={13}
strokeWidth={2}
className={regenerating ? 'motion-safe:animate-spin' : ''}
aria-hidden="true"
/>
{t('admin.trekApi.quota.regenerate')}
</button>
</div>
)}
</SettingsCard>
<ConfirmDialog
isOpen={confirming}
@@ -201,6 +211,6 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
confirmLabel={t('admin.trekApi.quota.regenerate')}
danger={false}
/>
</section>
</div>
);
}
@@ -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,45 @@
import {
PLACE_CONTRIB_FIELDS,
type PlaceContribField,
type PlaceContribFieldSwitches,
type PlaceContribState,
} from '@trek/shared';
import type { TranslationFn } from '../../types';
/** Every switch on: what the prompt proposes, and what an installation that never decided reads as. */
export const ALL_FIELDS_ON: PlaceContribFieldSwitches = {
website: true,
phone: true,
email: true,
opening_hours: true,
description: true,
image: true,
};
export const fieldLabel = (t: TranslationFn, field: PlaceContribField): string => t(`admin.contribute.field.${field}`);
/** The switches as a list, in the order the contract names the fields. */
export const fieldEntries = (switches: PlaceContribFieldSwitches): [PlaceContribField, boolean][] =>
PLACE_CONTRIB_FIELDS.map((field) => [field, switches[field]]);
export type ContribStatus = 'active' | 'paused' | 'off' | 'unavailable';
/** Where sharing stands, as the pill beside the title says it. */
export function contribStatus(state: PlaceContribState): ContribStatus {
if (!state.eligible) return 'unavailable';
if (!state.active) return 'off';
return state.pauseReason ? 'paused' : 'active';
}
/** The sentence under the title: why it cannot run, why it waits, or what it does. */
export function contribNote(t: TranslationFn, state: PlaceContribState): string {
if (!state.eligible && state.ineligibleReason) return t(`admin.contribute.ineligible.${state.ineligibleReason}`);
if (state.active && state.pauseReason) return t(`admin.contribute.pause.${state.pauseReason}`);
return t(state.active ? 'admin.contribute.noteActive' : 'admin.contribute.noteOff');
}
/** The refusal a decision can meet, as the server names it in `reason`, or null for any other failure. */
export function refusalReason(err: unknown): string | null {
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
return typeof reason === 'string' && reason ? reason : null;
}
@@ -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,
@@ -0,0 +1,31 @@
// FE-ADMIN-CONTRIBHOOK-001
import { act, renderHook, waitFor } from '@testing-library/react';
import type { PlaceContribState } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { placeContribRepo } from '../../repo/placeContribRepo';
import { usePlaceContrib } from './usePlaceContrib';
const STATE = { ineligibleReason: null, active: true } as unknown as PlaceContribState;
afterEach(() => {
vi.restoreAllMocks();
});
describe('usePlaceContrib', () => {
it('FE-ADMIN-CONTRIBHOOK-001: a withdrawal that went through is answered even when the state cannot be read again', async () => {
const state = vi.spyOn(placeContribRepo, 'state').mockResolvedValueOnce(STATE);
vi.spyOn(placeContribRepo, 'withdraw').mockResolvedValue({ withdrawn: { website: 2 }, cleared: 1 });
const { result } = renderHook(() => usePlaceContrib());
await waitFor(() => expect(result.current.state).toBe(STATE));
state.mockRejectedValueOnce(new Error('offline'));
let answer: unknown;
await act(async () => {
answer = await result.current.withdraw();
});
expect(answer).toEqual({ withdrawn: { website: 2 }, cleared: 1 });
expect(result.current.state).toBe(STATE);
expect(result.current.busy).toBe(false);
});
});
@@ -0,0 +1,65 @@
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { placeContribRepo } from '../../repo/placeContribRepo';
const KEY_RETRIES = 6;
const KEY_RETRY_MS = 3_000;
/**
* The admin's decision on sharing place details. Read once when the settings
* tab opens, so the prompt can come up without the card being scrolled to;
* every decision answers the new state, which replaces the old one.
*/
export function usePlaceContrib() {
const [state, setState] = useState<PlaceContribState | null>(null);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
// A fresh installation fetches its key on first use, so the first answer can
// be no_key; asking again a few times lets the prompt appear once it landed.
const read = (tries: number) => {
placeContribRepo.state(controller.signal).then(
(next) => {
setState(next);
if (next.ineligibleReason === 'no_key' && tries > 1) timer = setTimeout(() => read(tries - 1), KEY_RETRY_MS);
},
() => {
if (!controller.signal.aborted) setFailed(true);
}
);
};
read(KEY_RETRIES);
return () => {
controller.abort();
clearTimeout(timer);
};
}, []);
const decide = useCallback(async (body: PlaceContribDecisionRequest): Promise<PlaceContribState> => {
setBusy(true);
try {
const next = await placeContribRepo.decide(body);
setState(next);
return next;
} finally {
setBusy(false);
}
}, []);
const withdraw = useCallback(async () => {
setBusy(true);
try {
const result = await placeContribRepo.withdraw();
// The withdrawal went through; a failed re-read keeps the old state rather than reporting it failed.
await placeContribRepo.state().then(setState, () => undefined);
return result;
} finally {
setBusy(false);
}
}, []);
return { state, failed, busy, decide, withdraw };
}
@@ -20,7 +20,10 @@ const trip = (id: number, start: string | null, end: string | null) => ({
end_date: end,
});
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('rankPickerTrips', () => {
it('FE-AI-PICK-001: the running trip leads, then the soonest to come, then the latest past one', () => {
@@ -59,6 +62,9 @@ describe('rankPickerTrips', () => {
describe('useAssistantTripPicker', () => {
it('FE-AI-PICK-004: loads the trips once and ranks them; a failed load offers none', async () => {
// The hook ranks against the real clock; pin it so trip 7 keeps running.
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
vi.setSystemTime(new Date(`${TODAY}T12:00:00`));
const list = vi
.spyOn(tripRepo, 'list')
.mockResolvedValueOnce({ trips: [trip(7, '2026-10-01', '2026-10-30') as Trip], archivedTrips: [] });
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -1,6 +1,6 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own — every expense maps to
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
}
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { frameLine, frameLng } from '../../utils/lngFrame';
import { useLngFrame } from '../Map/lngFrameContext';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
positions: frameLine(frame, ferrataPositions(segment)),
casing,
litCasing,
line,
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
hit,
}))
);
}, [groups, paint]);
}, [groups, paint, frame]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
@@ -1,5 +1,7 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
} catch { return '' }
}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { relativeTime } from '../../../utils/relativeTime'
import { Tooltip } from '../../shared/Tooltip'
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
const { t, language } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
}
/**
+7 -4
View File
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no link into it.
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
{(guide.link || docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
{(guide.link || docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
{docs && (
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
)}
</div>
)}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no links into it.
const managed = useAuthStore(s => s.managed)
const closeHelp = useHelpStore(s => s.closeHelp)
const screenRoute = useScreenRoute(context?.route)
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const docs = onePerPage(context.docs)
const docs = managed ? [] : onePerPage(context.docs)
const trail = helpContextTrail(context).slice(0, -1)
const Icon = helpIcon(context.icon)
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
// No Help & Docs page on a managed install (App.tsx): guides only.
const managed = useAuthStore(s => s.managed)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
return (
<div className="trek-help-view flex flex-col gap-8">
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
</section>
)}
{!managed && (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
</ul>
)}
</section>
)}
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
@@ -102,6 +103,8 @@ beforeEach(() => {
// The time field reads time_format, so pin it: without this the cases that
// assert a 24h string depend on whichever test ran before them (#2067).
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
// A photo provider switched on for the install, so the External photos source is offered.
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
seedStore(useAddonStore, { addons: [] })
mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
import { useAddonStore } from '../../store/addonStore'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t } = useTranslation()
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Camera size={14} />
</button>
</Tooltip>
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
{/* Only where the install has a photo provider (Immich, Synology) switched
on, as on the phone: a source that can only say "nothing connected"
is not worth its width, and the other two share the row instead. */}
{photoProvidersOn && (
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
)}
</div>
{/* The gallery picker, directly below the buttons. Safari collapses
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
)}
{photoTab === 'external' && (
{photoTab === 'external' && photoProvidersOn && (
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
<div className="min-w-0">
@@ -0,0 +1,34 @@
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
import { describe, expect, it } from 'vitest';
import { journeyOpenAtEntryId } from './journeyOpenAt';
const entries = [
{ id: 3, entry_date: '2026-05-03' },
{ id: 1, entry_date: '2026-05-01' },
{ id: 2, entry_date: '2026-05-01' },
{ id: 5, entry_date: '2026-05-05' },
];
describe('journeyOpenAtEntryId', () => {
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
});
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
});
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
});
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
});
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
});
});
@@ -0,0 +1,40 @@
interface DatedEntry {
id: number | string;
entry_date: string;
}
/**
* The entry a journey opens on, or null for its first one.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now: a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*
* A link the owner published newest-first reads like a blog (#1614), so it
* opens on the latest day whether or not the trip is still running (#2851).
*
* Always the first entry OF the chosen day, not the last one before now: a day
* is read from its beginning.
*/
export function journeyOpenAtEntryId(
entries: readonly DatedEntry[],
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
): string | null {
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
const first = dated[0];
const last = dated[dated.length - 1];
if (!first || !last) return null;
let day = last.entry_date;
if (!newestFirst) {
if (today < first.entry_date || today > last.entry_date) return null;
const reached = dated.filter((e) => e.entry_date <= today);
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
}
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
return String(dayStart.id);
}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
act(() => result.current.openEntryById('99'));
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
});
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
return sync;
},
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
);
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
// Card 1 sits at the centre (150) in both positions: only the ends decide.
[0, 100, 200].forEach((left, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
const settleAt = (scrollLeft: number) => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
});
};
settleAt(300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(150);
expect(hook.result.current.activeIndex).toBe(1);
// Right to left: the scroll position runs negative from the start.
settleAt(-300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
});
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
@@ -87,6 +87,16 @@ describe('Navbar', () => {
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
render(<Navbar />);
expect(document.querySelector('[data-help-button]')).not.toBeNull();
await user.click(screen.getByText('testuser'));
expect(screen.queryByText('Help')).not.toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
const user = userEvent.setup();
render(<Navbar />);
+3 -1
View File
@@ -35,7 +35,7 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
{t('nav.settings')}
</Link>
{!managed && (
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
)}
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
@@ -1,8 +1,10 @@
import L from 'leaflet';
import { useCallback, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
import { useLngFrame } from './lngFrameContext';
import type { AssistantCamera } from './mapViewContract';
interface AssistantCameraControllerProps {
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
*/
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
const map = useMap();
// The points are framed the way the map draws them (#2975).
const frame = useLngFrame();
const getContainer = useCallback(() => map.getContainer(), [map]);
const userMovedAt = useUserMoveClock(getContainer, true);
const request = useCameraRequest(camera, userMovedAt);
useEffect(() => {
request((next) => {
request((camera) => {
const next = frame
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
: camera;
const duration = ASSISTANT_CAMERA_MS / 1000;
if (next.mode === 'pan') {
const centre = centreOf(next.points);
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
duration,
});
});
}, [map, padding, request]);
}, [map, padding, request, frame]);
return null;
}
@@ -1,7 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
import { useLngFrame } from './lngFrameContext';
import type { AssistantGhost } from './mapViewContract';
interface AssistantGhostLayerProps {
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
*/
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
const map = useMap();
// Pinned where the map draws the trip, in its longitude frame (#2975).
const frame = useLngFrame();
const handlers = useRef<GhostHandlers>({ onHover, onClick });
useLayoutEffect(() => {
handlers.current = { onHover, onClick };
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
const label = document.createElement('span');
label.textContent = ghost.name;
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
icon,
keyboard: false,
zIndexOffset: GHOST_Z_OFFSET,
})
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
.addTo(map);
return { remove: () => marker.remove() };
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
mounted.dispose();
elements.current = new Map();
};
}, [ghosts, map]);
}, [ghosts, map, frame]);
useEffect(() => {
setGhostHot(elements.current, hoveredId);
@@ -1,8 +1,11 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
const point = (event: LeafletMouseEvent) => {
const at = onRealWorld(event.latlng);
onPoint?.(line.ref, at.lat, at.lng);
};
return (
<FeatureGroup ref={group}>
<Polyline
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
return {
...line,
lines: line.lines.map((part) => frameLine(frame, part)),
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
};
}
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
const frame = useLngFrame();
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
if (!drawn?.length) return null;
return (
<>
{lines.map((line) => (
{drawn.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
+8 -4
View File
@@ -4,11 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import { useLngFrame } from './lngFrameContext'
import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
const frame = useLngFrame()
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
if (segments.length === 0) return null
return (
+5 -1
View File
@@ -4,10 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
const frame = useLngFrame()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const map = useMap()
const frame = useLngFrame()
const [layers, setLayers] = useState<PluginMapLayer[]>([])
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
// optimization — if the map has no pane API (e.g. a minimal renderer), the
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
if (layers.length === 0) return null
const pane = hasPane ? PANE : undefined
// Drawn in the map's longitude frame, with the trip (#2975).
return (
<>
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const key = `${layer.pluginId}:${layer.id}:${i}`
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
if (f.type === 'polyline' && f.points) {
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
}
if (f.type === 'polygon' && f.points) {
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
}
if (f.type === 'circle' && f.center && f.radiusM) {
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
}
return null
}))}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { pluginsApi, type PluginMapMarker } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame()
useEffect(() => {
if (tripId == null) { setMarkers([]); return }
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
return (
<>
{markers.map(mk => (
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
{(mk.label || mk.popupText || mk.url) && (
<Popup>
<div style={{ minWidth: 120, fontSize: 13 }}>
+13 -4
View File
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
const onMapClick = vi.fn()
const { unmount } = render(<MapView onMapClick={onMapClick} />)
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
expect(bound).toEqual(expect.any(Function))
// A click on the real world reaches the caller as the very event the map fired.
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapClick).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
})
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
const onMapContextMenu = vi.fn()
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
expect(bound).toEqual(expect.any(Function))
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapContextMenu).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
})
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
+46 -99
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
const frame = useLngFrame()
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
@@ -411,14 +360,15 @@ interface MapControllerProps {
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
map.setView([center[0], frameLng(frame, center[1])], zoom)
prevCenter.current = center
}
}, [center, zoom, map])
}, [center, zoom, map, frame])
return null
}
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const frame = useLngFrame()
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
}, [map, paddingOpts, frame])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
return null
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
import { useMarkerThumbs } from './useMarkerThumbs'
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const frame = useLngFrame()
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const frame = useLngFrame()
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
const photoUrls = useMarkerThumbs(places)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
const gpxTracks = useMemo(() => places.flatMap(place => {
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
}), [places, lngFrame])
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<LngFrameProvider value={lngFrame}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
/>
)}
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
positions={frameLine(lngFrame, spur.line)}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
<PluginMapLayers tripId={tripId} />
</MapContainer>
</LngFrameProvider>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
+11 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
@@ -725,7 +727,7 @@ export function MapViewGL({
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
@@ -925,7 +927,8 @@ export function MapViewGL({
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
const at = onRealWorld(e.lngLat)
onRouteClick(at.lat, at.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
@@ -944,35 +947,12 @@ export function MapViewGL({
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
@@ -1343,7 +1323,7 @@ export function MapViewGL({
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
})
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
@@ -1376,7 +1356,7 @@ export function MapViewGL({
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
@@ -1982,7 +1962,7 @@ export function MapViewGL({
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
if (fitPoints.length === 0) return
const bounds = new gl.LngLatBounds()
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
let fitted = false
const run = () => {
try {

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