Compare commits

...

361 Commits

Author SHA1 Message Date
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
Maurice 067ab89430 fix(calendar): give transport events a LOCATION and GEO from their endpoints
A car or other transport booking carried its route only in the description, so
a calendar app had no place to navigate to. Without a location of its own, a
transport event now takes the endpoint the traveller has to get to: the
destination of a drive of their own (car, bicycle) and the departure of a ride
they board, the side the per-leg events already use. A car's pickup and
drop-off events are located at their own endpoints. A location the booking
states keeps winning.

Closes #2909
2026-10-30 21:54:19 +01:00
Maurice 019c668744 fix(llm-parse): ask Anthropic models for the extraction tool instead of forcing it
Claude Sonnet 5.5, Opus 5.5 and Fable 5.1 reject tool_choice type "tool" and
"any" with a 400, so every booking import through the Anthropic provider failed
on them. The request now sends tool_choice auto with parallel tool use off,
which every Anthropic model and Messages-compatible gateway accepts, and the
system prompt tells the model to answer through the emit tool exactly once. A
model that still answers in text is reported as before.

Closes #2902
2026-10-30 21:54:19 +01:00
Maurice b953ece4b3 fix(journey): stop the entry views from shifting while photos and providers load
Opening an entry on the share page showed the hero at no height until the
original arrived, then pushed the story down, and the lightbox stayed black
until the full photo had loaded. The hero now reserves the photo's proportions
(4:3 when none were recorded) with its thumbnail behind it, and the lightbox
shows the thumbnail and a spinner until the original is in.

The phone entry sheet probed the photo providers again on every open and added
the External photos button once the answer came, squeezing the icons of the
other two buttons. The journey screen already knows the connected providers, so
it hands them to the sheet, and the icons keep their size.

The picked neutral mood was a grey tint on a grey sheet; a picked mood is now
framed in its full colour and announced as pressed.

Closes #2907
Closes #2908
Closes #2905
2026-10-30 21:15:38 +01:00
Maurice 94a9cbf070 fix(journey): draw the live Dawarich trail in the colour of its day
The live trail arrived in Dawarich's own day palette, counted from the first
recorded day, while a journey colours its days by the entries. A day's line and
its pins therefore wore different colours. The trail of a day that has entries
now takes that day's colour, the way the kept recordings already did.

Closes #2906
2026-10-30 21:15:37 +01:00
Maurice 8eebcea9f5 fix(journey): keep GL photo markers absolutely positioned so thumbnails sit on their coordinates
The journey map's photo thumbnails set position:relative inline on the marker
element. That overrides the engine's .mapboxgl-marker/.maplibregl-marker
position:absolute, so every thumbnail entered the normal flow below the ones
drawn before it and the engine's translate was added on top: the further down
the list, the further the photo drifted from its spot. The location dot had the
same inline position. Both now leave position to the engine's class.

Closes #2910
2026-10-30 21:15:37 +01:00
Maurice 5e00282309 maps: drop indexHitsOnly, which the search no longer needs, and keep isHit inside its module 2026-10-30 17:26:56 +01:00
Maurice 3b675eed60 airports: publish the airport of a code as a contract the place search reads
The search turned an airport code into its airport by reaching into the
airports domain's data module. airports.contract.ts publishes that lookup
instead, as the import boundaries ask.
2026-10-30 17:14:55 +01:00
Maurice 51bbd337c9 i18n: format the admin strings of the TREK API card 2026-10-30 16:57:52 +01:00
Maurice 4203dcd91f maps: keep the merge and Photon's geocoding in modules of their own
maps.helpers.ts and osm.client.ts had grown past the 1000-line limit. The
merge behind the search and suggestion lists, with its lead rules, moves to
maps-merge.helpers.ts; the search records and Photon's half of the
OpenStreetMap geocoding (search, suggestions, airport codes, identity,
reverse, element details) move to osm-geocoding.ts. OsmClient keeps the
same methods and hands them on, so no caller changes.
2026-10-30 16:52:12 +01:00
Maurice 1e5314d847 maps, routing, i18n: settle what the checks found
- The lead is Photon's own row first; a row of its name inside the lead
  only stands in where Photon's row went as the same place. Rows without a
  line below the name are never folded as reading the same.
- A miss of Photon where it is the geocoder no longer asks it a second time
  on the way to an empty answer.
- The transport words of the identity match use a Unicode word boundary
  (straße, estación) and match 駅 anywhere.
- Opening an OSM place reads the coordinate it was placed at explicitly.
- The ferry spans of a Valhalla leg move to valhallaFerries.ts, and a day
  cut into pieces is asked with one options object.
- "Limit" on the allowance tiles is "Daily limit", which no locale left
  as an English copy.
- Tests follow the new behaviour: no Nominatim behind Photon, zoom 13,
  and the expectations of the merge and edit-distance cases.
2026-10-30 16:36:22 +01:00
Maurice b98e530ffc wiki: describe the address search, the merged result list and the routing of the TREK services 2026-10-30 16:16:10 +01:00
Maurice deb125bedc admin: show the TREK API as its three services
The card names place search, address search and routing with what each
answers and what is left of today's allowance. Each service is a tile with
what is left as its figure, the day's use as a bar and used and limit as
badges; the state, the kind of installation, a key from the environment
and the time the count starts again sit as badges in the header. The
sources add the terrain tiles the routes take their heights from.
2026-10-30 16:16:09 +01:00
Maurice c508bb9758 routing: a slow route is not a down relay, and the relay takes whole days
A request to the TREK Valhalla that runs out its deadline (a long bike
route) is answered 504 and no longer rests the relay for every user or a
tab for ten minutes; that one day goes to OSRM. Only an unreachable host
or a server error rests it.

A day goes to the relay with up to twenty stops per request and no pause
between pieces; a request that ends up at the public FOSSGIS Valhalla is
cut to ten and paced, as before. The 1.1 s spacing of the server applies
to the public hosts only, not to an admin's own OSRM or Valhalla. The
relay keeps its connections open and asks for maneuvers only, which the
relay contract now accepts.
2026-10-30 16:16:09 +01:00
Maurice a55d4e786f maps: lead with the place a name stands for, and leave Nominatim to installs without the services
The suggestions and the full search now share one merge. Photon's hits are
ranked with places that have a Wikidata link before their unlinked
namesakes, and its context bias is narrower (zoom 13). The list opens on a
lead where Photon's answer has one: a country or region always; a town,
island or sight when it is called what was typed (or a near typo of it),
lies within 1000 km of where the traveller is looking and no place of that
name ranks above it. Rows of the lead's name inside it, the same element
under two ids and rows that read the same are shown once. A three-letter
code in capitals is asked for as the airport of the bundled list.

Where Photon is the geocoder it answers alone: an empty answer and a miss
are the answer for search and imports, the reverse lookup asks again
within 50 km, a Google place is matched inside a box through Photon, and
an opened OpenStreetMap place is placed by Overpass and named by Photon.
Nominatim stays the search of installations without the TREK services or
with an operator's own Nominatim.

Measured on 5000 places from Wikidata: the right place first while typing
48 % before, 65 % now; full search 53 % to 68 %; no repeated rows.
2026-10-30 16:15:59 +01:00
Maurice 5a270abc05 fix(booking-import): give the fare keeper reduce an initial value 2026-10-29 21:40:03 +01:00
Maurice f50a1dabe5 chore(ci): lower the knip baseline for the kitinerary types 2026-10-29 21:25:13 +01:00
Maurice 1058849388 test(booking-import): read the endpoints without a non-null assertion and lower the any baseline 2026-10-29 21:12:58 +01:00
Maurice 299beb65b1 fix(roadtrip): hand a hire car without a return day back on its pick-up day
A car booking without an end day is a one-day booking everywhere else in
the plan, but the road trip read it as a car never handed back: only its
pick-up desk stood on the road. Two one-way car bookings on one day
(Bologna to Villach, then Villach to Klagenfurt) therefore drove from the
first pick-up to the second and on to whatever came before, counting the
road between the two cities twice and never reaching Klagenfurt. This
applies to the planner, the phone and calculate_roadtrip alike, since all
three seat the desks through the shared carrierSeam.

Such a car now has its return desk on the pick-up day. A return without a
time goes back before the next car of that day is collected rather than
closing the day behind it, so each one-way car drives its own road once.
Bookings with a return day and cars without a located return desk are
unchanged.

Closes #2894
2026-10-29 20:51:28 +01:00
Maurice 23b7f6d71c fix(bookings): count a multi-segment rail ticket's fare once
A rail, bus or ferry ticket with changes arrived as one booking per
segment, each with a linked cost for the full fare, so the trip total
was the fare times the number of segments.

Import: the KItinerary mapper now groups consecutive train segments
under one booking reference that connect (same station by name or
within 500 m, no return, within a day) into one train booking with
stops and metadata.legs, the way connecting flights already were. The
connection rule is shared between flights and ground transport, and a
stop carries the onward departure for flights as for trains, as the
planner form and the MCP tools write it. The three copied ground
mappers are folded into one.

Within one such booking a fare repeated on every segment is booked
once, and fares that differ per segment are added in whole cents. That
summing now applies to connecting flights too, which used to keep the
first leg's price only.

Segments of one order that do not join into one booking (an outward
and a return trip, a gap, a change of station named two ways without
coordinates, bus and boat tickets, Rail&Fly) are compared across the
document: when every priced transport booking of a reference carries
the same amount in the same currency, the first to depart keeps it and
the others lose it. Amounts that differ stay. This runs on what
KItinerary extracted only.

AI import: the repeated-total safety net from #2651 now covers train,
bus and ferry tickets and any mix of them with flights, still only
when the document prints that amount on a total line.

MCP and assistant: create_transport says that a ticket with several
segments is one booking with endpoints[] and legs[], and the price
fields say they hold the whole booking's cost. When a new booking
repeats the price an earlier booking with the same confirmation number
already carries in the trip's currency (its own currency, or the
trip's when it names none), create_transport and create_reservation
still save it and return a warning naming the earlier booking. REST is
unchanged.

Existing duplicated costs are left alone: they cannot be told apart
from two tickets that really cost the same, and deleting expenses
automatically would change settled balances. They can be removed in
the Costs tab.

Closes #2895
2026-10-29 20:51:28 +01:00
Maurice f85b9dc3ff fix(service-identity): call the dialect self-check's scrypt input inputSha256
A field named passwordSha256 next to a hex literal reads as a hard-coded
password to static analysis.
2026-10-29 20:04:40 +01:00
Maurice 3c38a87271 chore(knip): treat the registration dialect as an entry, the release job loads it by path 2026-10-29 20:04:40 +01:00
Maurice 12e22c258a ci(release): stop on a stale dialect, check it like CI would, give prereleases their own
A stable release without a dialect of its own now ships the one on main only while that one has 90 days left; dialect 0 or less stops the run before anything is committed, unless allow_stale_dialect is ticked. Fetching and checking moved into registration-dialect.yml, which never fails its caller and also type-checks the module under the strict config and lints it with --max-warnings 0, since the bump commit skips CI. The bump and everything after it run whatever became of the dialect job. Prereleases ask the issuer for a dialect of their -pre.N version and build it into the image without committing it. The release check reads the module token by token against an allowlist of keywords, globals and member names, refuses any assignment to a property, computed access and regular expressions, and names Object, JSON and defineProperty among the forbidden. The issuer's kitchen-sink sample is gone; the tests use a hand-written dialect instead.
2026-10-29 20:04:40 +01:00
Maurice cd5ce4b1d9 docs(wiki): say how long a key renews and what a new registration needs 2026-10-29 20:04:40 +01:00
Maurice 6d245ab4da ci(release): ship the registration dialect with the version bump
The stable release asks the issuer for the registration dialect of the
version it cuts and commits it in the bump commit. Fetching and checking run
in a job of their own that holds no write token and keeps no git
credentials: scripts/ci/check-registration-dialect.mjs refuses a file that
imports or names anything beyond hashing, then runs it on its self-check
inputs. Only a file that passes reaches the bump as an artifact. When the
issuer is unreachable or its answer fails the check, the release goes ahead
with the dialect already on main and says so in the log and the summary.

Prereleases get no dialect of their own; dev takes the stable one through
the merge of main into dev after a release, and docker-dev.yml warns while
dev still speaks the original registration or a dialect older than 150 days.
2026-10-29 20:04:40 +01:00
Maurice 3ca73d432b feat(service-identity): register through the dialect of the running release
Registration now goes through a registration dialect module: the paths of
the challenge and the registration, the bytes scrypt runs over and the body
and headers of the request. Each stable release will carry the dialect the
TREK services issuer writes for it; until the first such release the module
speaks the original registration, so nothing changes on the wire.

An issuer that no longer takes the release's dialect answers 410. TREK then
says so once per process, waits a day (or what Retry-After asks) and keeps
searching through the shared pool. The issuer client only lets a dialect
register at the issuer's own paths, with X-TREK-* headers that are not the
gate's and a body of registration size.

Renewal needs no dialect, and an expired key may now be renewed for a year
instead of 30 days, the same period the issuer allows, so an installation
that was offline for long comes back without an update. The key format
ignores the new optional dialect claim; the shared vectors gain one key that
carries it.
2026-10-29 20:04:40 +01:00
Maurice e6c8fee3e5 fix(journey): keep the card-click ref in JourneyMap and type the pin event test
The marker effect reads the card handler through the component's own ref
again, so exhaustive-deps sees a ref it knows, and the card hook takes it
alongside the map's other refs. The pin event assertion types the mock
calls it maps under strict mode.
2026-10-29 17:50:43 +01:00
Maurice 2dbaf6b029 fix(journey): hold the client gates on the journey map changes
The phone card logic moves out of JourneyMap into useEntryCard, which brings
the component back under the line limit. The new card tests reach the
card element through a guard instead of non-null assertions, the public
hook test reads the hook through renderHook, the GL card tests use
mock.lastCall where Array#at is outside the client's lib, and the
complexity baseline drops to the count the branch now holds.
2026-10-29 17:39:37 +01:00
Maurice c14b572038 refactor(server): read the share link's journey entries through Kysely
listPublicEntries changed for #2886, and the repository rules move a
touched QueryBuilder call to the entity methods or Kysely. Kysely keeps
the method's answer what it was, the stored row as SELECT * reads it,
which the entity methods would hand back as entities with a journey
reference in place of journey_id. The query-api baseline drops to 8.
2026-10-29 17:26:51 +01:00
Maurice c0e951fc8e chore(journey): keep the map stop and connection types to their files
ConnectionStop, EntryConnections and JourneyMapStop are only used in the
signatures of their own modules, and knip counts an export nothing
imports. The connection tests take the FE- prefix and header the other
journey tests carry.
2026-10-29 17:26:51 +01:00
Maurice c71ecc8f76 fix(journey): drive the phone's Leaflet card from the active pin
The card a phone host opens its entry from was still a tooltip bound to
the pin, so Leaflet opened and closed it on the pin's hover and focus. A
tap on the card reaches a phone browser as the pointer leaving the pin
first: the pin's mouseout closed the card between the tap and its click,
and on iOS a content change in that phase can drop the click entirely.

Where a host wires onMarkerCardClick, the pins now bind no tooltip. One
card per map stands over the active pin, the way the GL popup does: it
follows highlightMarker, leaves when no entry is active or the entry
pins are switched off, and only a change of entry moves it. A press on
it never reaches the map. Desktop hosts keep the hover label on each pin.

The card tests run against the handlers the pins really get and the
card the map really adds, and the file parses again: two test names held
an unescaped apostrophe.
2026-10-29 17:26:51 +01:00
Maurice a1c7ea209b fix(journey): open the entry when its marker card is tapped on the phone
On the phone journey screen and the public share page on a phone, a tap on
a numbered pin brings up the entry's card above it, but the card could not
be tapped: the GL popup and the Leaflet tooltip both let the pointer fall
through to the map, and no phone host gave the card anything to do.

The map now takes onMarkerCardClick, and both renderers make the card open
that entry once a host wires it. The GL popup handles it in its delegated
listener next to the photo strip, which keeps opening the photos where the
desktop asks for that. Leaflet handles it on the tooltip element itself:
a press that reached the map container let Leaflet focus the map, which
blurred the pin and closed the card before its click landed. The phone
screen and the timeline open the entry through the carousel hook, the
same way a tap on the active card does. The desktop asides keep the card
as a hover label.

The unused hideMarkerTooltip prop goes, together with the docs that still
said the phone hides the card.

Closes #2885
2026-10-29 17:26:51 +01:00
Maurice fe24dd03c5 fix(journey): number map stops the same way in the journal and on the share link
The share link has counted its map stops through the whole journey since
#1962, while the owner's journal still restarted the count every day, so
the same stop read 66 on the link and 6 in the journal.

Both views now take their stops from one helper, journeyMapStops: located,
non-skeleton entries in the server's reading order (date, sort_order, id),
numbered across the journey. A draft keeps its pin in the journal but gets
no number, so the published stops after it carry exactly the numbers the
share link shows. The public entry query gains the id tiebreak the owner
query already had, so entries sharing a sort_order come back in the same
order on both sides.

Closes #2886
2026-10-29 17:26:51 +01:00
Maurice 66bff5c342 fix(journey): drop the entry connection where a recorded day shows the way
The journey map joins every located entry with a faint dashed line in the
order of its pins. It drew that line through everything, also across days
whose real route is on the map as a kept recording or the live Dawarich
trail, so straight guesses fanned out over the recorded track. The phone
has no layer switches, so the line could not even be hidden there.

A recorded track now carries the day it belongs to (an optional `date` on
the track contract, set by every builder of recorded lines: kept days, the
shared page's recordings, the live Dawarich trail and the day import
preview). A pure model splits the connection into the stretches still open
and those whose days were all recorded, the night between two recorded days
included. Both renderers draw only the open stretches with the entries; the
covered ones sit on a layer of their own that shows while the reader hides
what was recorded, so the guess comes back exactly where nothing else
explains the way.

The live trail is now matched to a kept day by that date instead of reading
the track name as one.

Closes #2888
2026-10-29 17:26:50 +01:00
Maurice 55b11f792a fix(journey): keep map photo and mark clusters stable while panning
The journey map grouped its photo thumbnails and its plan and stay marks
on a grid laid over container pixels. Every pan shifted that grid against
the map, so photos near a cell edge changed groups after each move: the
count badges jumped and the thumbnail switched to another photo, although
the zoom never changed.

Both renderers now bucket in world pixels at the current zoom, Leaflet
through its own CRS (so GCJ-02 basemaps stay right) and GL through a
plain Web Mercator projection that ignores centre, bearing and pitch.
Panning keeps every group as it is; only a zoom regroups.

Closes #2889
2026-10-29 17:26:50 +01:00
Maurice e8fbba236d refactor(bookings): hand the notes and link to the shared lower part as form fields
The booking and transport dialogs each spelled out the notes and the link
as four value and change props, and with the saving flag the two calls
became one copied block. The shared lower part now takes the form and its
setter, as the dialogs hold them, and writes the two fields itself.
2026-10-29 15:50:09 +01:00
Maurice 97455da6ca test: type the follow-up steps and the Atlas visit filter as they are used
The editor follow-up always hands the planner an afterSave, and its type now
says so, so the hook test calls it under strictNullChecks. The travel stats
test reads a country without a status as visited, as the standing helper
does for the hand-marked list. The client baseline holds the one non-null
assertion the count missed.
2026-10-29 15:36:37 +01:00
Maurice 2a51a1a842 test: hold the branch's tests to the type and lint gates
The booking save helper answers a Reservation the way the dialogs' onSave
does, and takes the payload the sheets read back. The export tests resolve
their routing promise without non-null assertions, the autocomplete test
reads its own console spy, and the Photon tally test asks its five dropped
questions together instead of awaiting in a loop. The Photon address line
falls back to empty when a feature has none.

The repair test now expects the dashboard to place the cleared beach in
Germany through the borders, as the Atlas map does since #2875. The client
lint baseline follows the counts this branch lowered.
2026-10-29 15:24:49 +01:00
Maurice 724857fc73 docs(atlas): say what the country resolver costs the dashboard, and why
The resolver's comment claimed the places of a city trip share their
coordinates, which they do not. It now says what an uncached place costs
per count and why the dashboard does not queue the Atlas region lookup:
that lookup falls back to Nominatim, and a dashboard load must not send a
user's places to a third party.

The #2875 cases continue the file's AUTH-DB numbering.
2026-10-29 15:11:39 +01:00
Maurice 28a577df5e fix(maps): keep a slow Photon from holding back the suggestions
The suggestions waited for Photon beside the index for up to its full
2.5 s deadline on every keystroke, and their misses and spent allowance
paused Photon for the search and the reverse lookup as well.

Once the index has answered, Photon now gets 300 ms more and is then
called off; with nothing from the index it is still waited for. The
keystroke question has a shorter deadline and its own tally of misses and
pauses, a question called off by the list counts as no miss, and a pause
of the search still holds the suggestions back.

Refs #2877
2026-10-29 15:11:39 +01:00
Maurice 64f370fd0e fix(pdf): give up a routing export once its dialog or sheet is gone
A PDF of a long trip routes for minutes, and only the skip button could
stop it. Closing the export dialog, hiding the phone sheet or leaving the
trip left the legs going out and opened the document later on whatever
page the user was on. Closing now stops the routing and the document never
opens; skipping still prints without the remaining legs.

The plan filter moves from TripPDF to pdfScope beside the other plan
predicate, which keeps TripPDF under its size entry. The routing test name
gets its quotes back, and the export dialog and sheet tests cover the leg
count, the skip button and closing while routing.
2026-10-29 15:11:32 +01:00
Maurice 00ac889139 fix(bookings): add the next imported booking instead of writing over the last one
The follow-up kept the booking a save stored until the editor session
changed, and on the phone the session was only the open flag. The import
review swaps the draft without closing the sheet, so the second booking of
the same kind went out as an update of the first and lost its linked cost.

The stored booking now lives only as long as a failed try needs it, and
the phone sheets name their session the way the desktop dialogs do, by the
booking or draft they hold. The sheets also seed their form from that
session, so the next draft of the review shows its own fields.

The follow-up hook gets its own test, the notes block test passes the
saving flag the component now takes, and the swallowed-catch baseline
follows the one catch the save merge removed.
2026-10-29 15:11:25 +01:00
Maurice 9ab2fc30ff fix(atlas): count a place's country on the dashboard the way the Atlas map does
The passport card and the last/next trip countries read place countries
from the place_regions cache alone, while the Atlas map falls back to the
bundled borders for a place without a cached region. A place in Vatican
City never gets a region row (the region bundle has no VA features and
Nominatim names no region there), so the map counted VA and the card did
not. The same gap hid any country whose place was still waiting for its
background lookup, or whose lookup had failed.

Both views now resolve each place through one helper: the cached country
first, the borders otherwise, once per location. The dashboard reads every
place of a started trip with its cached country instead of the distinct
cached codes, and the trip countries count resolved places the same way.
The MCP travel stats tool and the public stats API use the same service
methods and are fixed with it.

Closes #2875
2026-10-29 14:52:47 +01:00
Maurice 3238fbc517 fix(pdf): route every leg of the trip map and mark what is left unrouted
The PDF export routed the whole trip under an 8 s deadline while the router
is asked one leg at a time, a second apart. A cold month-long trip got about
seven legs routed per export; the rest printed as straight lines at "0 m"
under a total that looked final, and every further export routed a few more
days from the device cache.

The export now waits for every leg, bounded by the request pacing, the
rate-limit retries and a per-leg timeout. The export dialog and the phone
sheet count the legs while they route and offer to print without the rest.
Any leg left without a road is marked in the document: the day reads "not
routed" or "at least ...", the total says it is a floor, and a note names
how many legs are missing. The planner overview shares the same wording.

The PDF also routes what the planner map routes now: the road trip's vias,
the multi-day tour stages and the reader's distance unit.

Closes #2876
2026-10-29 14:48:13 +01:00
Maurice c03c40d287 fix(mobile): line the day rail up on today once the trip splash lifts
The opening day is seeded while the loading splash still covers the shell,
so the rail's auto-scroll ran before any chip existed and never ran again:
its dependencies did not change when the rail first mounted. A trip opened
mid-way showed today's day below a rail stuck at day 1.

The rail element is now a dependency of the alignment, so the active chip
is brought into view when the rail appears, including after re-entering
the trip. The first alignment jumps instead of gliding across a rail
nobody has seen yet, and runs before paint; later day changes still glide.

Closes #2880
2026-10-29 14:28:22 +01:00
Maurice b06e3e6266 fix(bookings): keep the editor open until a booking's files are uploaded
Saving a booking or a transport closed the editor and reported success
before the travellers and picked files were written. A failed upload left
a booking without some of its PDFs, no list to retry from, and a create
form that would add a duplicate when saved again. On desktop a file picked
on an existing booking went up at once and vanished when that failed.

The planner's save now takes the editor's follow-up as a step it waits
for: it stores the booking, runs the traveller write and the uploads, and
only then says so and closes. Each file that lands leaves the list, so a
failure leaves exactly the files still to send, and the editor keeps the
stored booking so the retry updates it instead of creating another one,
without a second linked cost or stay. All four editors (desktop booking
and transport dialogs, both phone sheets) share this through
useBookingFollowUp, an import review only moves on once an item's files
are up, and desktop files picked on a saved booking wait for Update like
on the phone.

Closes #2883
2026-10-29 14:20:57 +01:00
Maurice 7d669cb5c3 feat(maps): ask Photon beside the index for search-as-you-type suggestions
The suggestion list asked only the TREK index and its OpenStreetMap layer,
because Nominatim's usage policy rules out autocomplete. The layer is a slice
of OpenStreetMap, so many places showed up only once the full search was sent
(#2877). Photon is built for this keystroke: the suggestions now ask it in
parallel with the index and interleave its rows, dropping any the list
already holds, by OSM id or by the search list's same-place rule.

Without Photon (services off, an operator's own Nominatim, a paused or failed
answer) the list reads exactly as before. A failing index no longer throws
away what Photon found, and the Google-only switch skips Photon as it skips
the index.

Refs #2877
2026-10-29 13:50:37 +01:00
Maurice 81b3657528 fix(mcp): name the tuple check for what it holds, describe a restored map point as [lat, lng]
MCP-SCHEMA-005 finds tuple keywords, it does not validate against the 2020-12 metaschema, so the name now says that. A restored map point lost its range and order to the tuple fix; the description keeps the order visible to a model that builds a page itself.
2026-10-29 13:49:41 +01:00
Maurice 6f4d7d9066 fix(mcp): keep restore_book_page free of tuples so ChatGPT accepts the tool list
With the Journey addon on, ChatGPT refused every TREK tool: restore_book_page
took a map's path and roads as [lat, lng] tuples, which the SDK writes as the
draft-07 array form of items, and JSON Schema 2020-12 refuses that form.

The restore now takes a map point as two numbers and parses each page as a
spread before it is put back, so lat and lng stay checked and a point out of
range is an invalid_op instead of a thrown parse. MCP-SCHEMA-005 holds every
tool's input schema free of tuple keywords.

Closes #2879
2026-10-29 13:45:53 +01:00
Maurice 00684f4202 chore(client): lower the complexity baseline to the 219 the source holds 2026-10-29 13:34:28 +01:00
Maurice c4a39171f2 fix(client): narrow the route profile and Valhalla answer types, keep the stop check reading the whole log
The built-in profile check returned the open profile key and the run request
returned an unnarrowed answer, both of which the client typecheck refused. The
new road trip tests asserted with non-null assertions where optional calls do.

The graceful stop step piped docker logs into grep -q under pipefail, so grep
leaving early after its match could fail the step with SIGPIPE although the
server had logged SIGTERM.
2026-10-29 13:34:28 +01:00
Maurice 10e0e59c3f fix(server): keep no OSRM stand-in for a day the TREK Valhalla routes
A road trip day OSRM drew while the relay could not answer was cached for five
minutes, and a key added or removed kept serving the old engine's answer. The
stand-in is no longer cached and the key's availability is part of the cache
key. A walk kept off the boats may still go to FOSSGIS, which is now said and
tested.
2026-10-29 13:34:28 +01:00
Maurice e3a8c6fb8a fix(server): tell a user over the routing allowance when to ask the relay again
The per-user 429 carried no Retry-After, so the planner left the relay alone for
its full ten minutes and routed every day with OSRM meanwhile. It now answers
like the installation limit does, with the minute the window takes.
2026-10-29 13:34:28 +01:00
Maurice 7dc313c567 fix(maps): say which engine drew a day and keep Valhalla days found while the relay rests
The route cache key followed the relay's rest, so after one failed relay request
(always the case offline) every day the device held only from Valhalla missed
the cache for ten minutes. The key now follows the configured provider, and a
resting relay only stops the question from going out.

A route now names the engine that drew it. A day OSRM drew because Valhalla
could not answer is OSRM's on the rail, and its other ways are asked of OSRM,
instead of being labelled Valhalla's and compared with Valhalla offers.

An own Valhalla gets the relay's twelve second deadline, and a day that runs it
out goes to OSRM without a second wait. A day that avoids something keeps its
old cache key, so the days a device already holds stay found. The leg segment
and the offer types move to modules both engines import.
2026-10-29 13:34:28 +01:00
Maurice e3f542c8b7 docs(wiki): describe Valhalla routing every day and OSRM taking over a day it cannot answer 2026-10-29 13:34:28 +01:00
Maurice ba6428d7f1 feat(admin): say that the routing service calculates every route with OSRM taking over
The hint under Routing service now explains that the TREK services and an
own Valhalla calculate every route, that OSRM calculates a day they cannot
answer, and that FOSSGIS keeps Valhalla to what OSRM cannot do. In all 27
languages.
2026-10-29 13:34:28 +01:00
Maurice 29dc967d29 feat(server): route the road trip days through Valhalla on the server as the planner does
Where the TREK services or an own Valhalla route the days, the MCP road trip
router asks Valhalla for a day that avoids nothing, through the relay alone,
and OSRM routes the whole day when it cannot answer. Requests carry twenty
locations unless they may end up at FOSSGIS, which keeps ten.
2026-10-29 13:34:28 +01:00
Maurice 8fd025e003 feat(client): route every day through Valhalla where the TREK services or an own Valhalla answer
With the TREK services or an own Valhalla, the day plan, the road trip and
the other ways of a leg are routed by Valhalla on foot, by bike and by car,
twenty locations a request. A day it cannot answer (relay refusal, time out,
no way for a leg) goes to OSRM whole and without a word; it is never handed
to FOSSGIS unless it avoids something only Valhalla can avoid. With FOSSGIS
nothing changes: OSRM routes the days and Valhalla the avoidances.

A Valhalla answer is cached under its own key beside the OSRM one, so
switching engines never serves a day from the other engine. The Valhalla
day routing moves out of RouteCalculator into valhallaDayRoute.ts.
2026-10-29 13:34:28 +01:00
Maurice ec8df01f85 feat(shared): name the Valhalla costing of each profile and the providers that route every day
VALHALLA_COSTING maps driving, walking and cycling to auto, pedestrian and
bicycle once for the planner and the server. valhallaRoutesEveryDay says
whether the resolved provider carries every day: the TREK services and an
own Valhalla do, FOSSGIS and no Valhalla leave the days to OSRM.
2026-10-29 13:34:28 +01:00
Maurice 79260ad737 fix: name the routing and allowance labels where the key check finds them and keep the new tests within the lint baselines 2026-10-29 13:34:28 +01:00
Maurice 0426ce7f01 docs(server): name TREK_PHOTON_URL in the services config and the alternates limit of the TREK Valhalla 2026-10-29 13:34:28 +01:00
Maurice f76b8d5d9c fix(server): put a Photon house number before its street as Nominatim does 2026-10-29 13:34:28 +01:00
Maurice 8c11833fb1 fix(server): ask Nominatim when Photon finds nothing and keep bulk lookups off Photon
An empty Photon search, or one without a hit that has an OpenStreetMap id, is asked again at Nominatim as the reverse lookup already was. The background lane goes to Nominatim alone, a reverse lookup leaves Nominatim half of the caller's deadline, and a 429 with Retry-After pauses Photon for that long.
2026-10-29 13:34:28 +01:00
Maurice 45727b3d3b fix(client): ask FOSSGIS when the routing relay times out, refuses the body or is missing
A route or height question through the relay now gives up after 12 s. A 400 without Valhalla's error_code and a 404 or 405 count as the relay unable to answer instead of no way or no Valhalla, and a Retry-After sets how long the tab leaves the relay alone.
2026-10-29 13:34:28 +01:00
Maurice 686592437a fix(server): rest the routing relay after a failure and keep it under the installation's minute
A hung TREK Valhalla cost every leg, height chunk and trace the full 30 s before FOSSGIS answered. The relay now waits 10 s for a route or heights, rests a minute after an error or timeout and answers 503 at once meanwhile, follows a 429's Retry-After (an hour at most), passes that header on to the browser, and holds the installation to 100 questions a minute below the gate's 120 (60 per user).
2026-10-29 13:34:28 +01:00
Maurice 1da7d0627a fix(server): judge the address search in the admin status by TREK_PHOTON_URL and an own NOMINATIM_URL
The status asked a fixed Photon origin and compared NOMINATIM_URL with the public address, so a moved Photon or an explicitly set public Nominatim showed the wrong service as in use. It now reads the same photonUrl and nominatimOwn the Photon client decides by. TREK_VALHALLA_URL joins the commented compose variables.
2026-10-29 13:34:28 +01:00
Maurice bc62eb0d6f feat(admin): show the allowance of place search, address search and routing in the TREK API card
GET /api/admin/service-identity lists places, photon and valhalla, each with
today's allowance read fresh from /v1/quota of its own host (cached for 20
seconds per host) and whether this instance asks it right now: Photon only
beside the public Nominatim, the TREK Valhalla only where the routing choice
comes to it. Photon and Valhalla are asked only with a key and only while
TREK_SERVICES_HOSTS lists them. The last allowance an answer stated is kept
per host for when a host does not answer.

The card lists the three services under each other in the look it had,
a service this instance does not ask dimmed and named as such.
2026-10-29 13:34:28 +01:00
Maurice 091ebed8e4 feat(routing): let the admin pick FOSSGIS, the TREK services or an own Valhalla
routing_valhalla_provider (fossgis, trek or custom) sits beside the routing
URLs under Admin, Default user settings, admin only and locked on a managed
install. Without a choice an own Valhalla is custom, an own OSRM alone asks
no Valhalla, and otherwise the TREK services answer.

The planner asks them through the same-origin relay, so no connect-src
changes. A relay that answers 401 (a shared page without a sign-in), 403,
413, 429 or 5xx sends the question to FOSSGIS without a word and is left
alone for ten minutes. A long line to describe goes as a polyline.
2026-10-29 13:34:28 +01:00
Maurice 80183bc986 feat(server): relay Valhalla routing to the TREK services with the installation key
POST /api/routing/valhalla/route, /trace_attributes and /height ask the
Valhalla at TREK_VALHALLA_URL (default https://valhalla.liketrek.com) for a
signed-in user. The body is held to the shared contract (64 KB, at most 20
locations), every question counts against 120 a minute per user, and the
installation key goes along only to a host TREK_SERVICES_HOSTS lists.
Valhalla's answer is passed on as it came, refusals included.

The road trip router asks the same relay when the routing choice lands on
the TREK services, so the MCP road trip, city walk and hike tools share its
limits, and hands a question it cannot answer to FOSSGIS.
2026-10-29 13:34:28 +01:00
Maurice 250a7d4eb7 docs(wiki): describe the Photon address search and TREK_PHOTON_URL 2026-10-29 13:34:28 +01:00
Maurice 256f8c08e2 feat(server): ask the TREK services' Photon before Nominatim for search and reverse lookups
An installation that holds a key for the TREK services and has no
NOMINATIM_URL of its own now sends the OpenStreetMap search and the
reverse lookups to photon.liketrek.com first. The answers are read into
Nominatim's shape, so the place records stay the same, and every miss
(refused key, allowance spent, timeout, error, foreign body) falls back
to Nominatim, with a one minute pause after four misses in a row.

TREK_PHOTON_URL points at another copy, and the default of
TREK_SERVICES_HOSTS now lists photon.liketrek.com and
valhalla.liketrek.com beside places.liketrek.com.
2026-10-29 13:34:28 +01:00
Maurice 4ab3244f8f fix(server): mark the identity reset as writing its new key on its own
Regenerating clears the stored key at once and writes the new one only after the issuer answered in the background, so the two writes cannot share a transaction.
2026-10-29 13:34:28 +01:00
Maurice cb546e4a72 refactor(server): move the identified Places request into a file of its own
The instance token and the request that carries the installation key leave trek-places.client.ts, which had grown past the 1000-line limit.
2026-10-29 13:34:28 +01:00
Maurice eeae904867 fix(server): solve the proof of work and read the revocation notice without awaiting in a loop
The proof of work and the bounded read of a 403 body recurse instead of looping, the background-work checks compare against null instead of testing a promise, and the tests repeat their requests through oneByOne. The renewal test loads the stored key before its first request, and the panel test picks the last confirm button without Array.prototype.at.
2026-10-29 13:34:28 +01:00
Maurice 414a7ad98f fix(server): fetch a refused registration challenge once more instead of backing off 2026-10-29 13:34:28 +01:00
Maurice 58e006cfd9 docs(wiki): say that requests are counted per installation and per network 2026-10-29 13:34:28 +01:00
Maurice 476dbef7dc fix(server): renew service keys on the next request and stop sending expired ones
An expired key is no longer sent; the request goes through the shared pool
while a due key, or an expired one within its grace, is renewed in the
background. Refusals in one burst count once, a refused renewal counts like
a refused search, and a Places request whose key is refused is sent once
more without it.
2026-10-29 13:34:28 +01:00
Maurice b53751344b fix(server): ask the issuer for the allowance of a key only when TREK_SERVICES_HOSTS lists it 2026-10-29 13:34:28 +01:00
Maurice 01c0f8b64c fix(server): rotate the stored service key with ENCRYPTION_KEY and pin every encrypted app setting 2026-10-29 13:34:28 +01:00
Maurice 44add57109 fix(client): hide the new identity action on a managed install 2026-10-29 13:34:28 +01:00
Maurice afacf00c9f docs: document TREK_SERVICES, TREK_SERVICES_KEY and the daily allowance
docker-compose.yml and the Helm chart name the switch and the key (the key in the Secret, never the ConfigMap), and the wiki explains the installation key, what it carries, where it goes and the admin view of the allowance.
2026-10-29 13:34:28 +01:00
Maurice baf69da27f feat(client): show the TREK services allowance on the TREK API card
A calm panel under the card's facts: a dot with the state, the tier, a bar of today's use against the limit, what is left and the local time the count starts again. It reads when the card comes into view and every 30 seconds while it stays there and the tab is in front, says why when the services are off, revoked or cannot be read, and offers a new identity behind a confirmation unless the key comes from the environment.
2026-10-29 13:34:28 +01:00
Maurice 01ae91c939 feat(server): give each installation a key for the TREK services and report its allowance
A new service-identity domain registers the installation with places.liketrek.com on its first Places request, in the background and behind a light scrypt proof of work, keeps the key encrypted in app_settings, renews it from day 60 through a daily job, drops it after three 401s in a row with up to six hours of jitter, and freezes it on a revocation until an admin starts over. TREK_SERVICES_KEY wins and turns registration off; TREK_SERVICES=off sends no key; a mirror set with TREK_PLACES_URL never registers and never sees the key, which goes only to the hosts in TREK_SERVICES_HOSTS over https.

The Places client takes its headers from the identity port, sends a TREK/<version> User-Agent and keeps the instance token. A 401 or 403 does not open its breaker. GET /api/admin/service-identity reports the state, tier and today's allowance, asking the issuer at most every 20 seconds; POST .../regenerate starts a new identity.
2026-10-29 13:34:28 +01:00
Maurice f19f0a33b2 feat(shared): add the service identity contract and the allowance texts of the TREK API card
Zod schemas for what the key issuer answers (challenge, key grant, quota) and for GET /api/admin/service-identity. The card no longer claims there is no quota: every locale now says there is no key to enter, a fair daily allowance per installation, and that only the number of requests is counted.
2026-10-29 13:34:28 +01:00
Maurice e5fc735e13 docs(readme): close the gap above the sponsor line 2026-10-29 11:38:54 +01:00
Maurice d2ec9eabde docs(readme): describe TREK as the trip ecosystem it has become 2026-10-29 11:38:54 +01:00
Maurice 9128039c38 docs(readme): let the four status badges continue the badge row 2026-10-29 11:38:54 +01:00
Maurice dd6f41f768 docs(readme): give the license, release, pulls and stars badges the style of the row above 2026-10-29 11:38:54 +01:00
Maurice 1a73b22810 docs(readme): show the TREK logo alone at the top 2026-10-29 11:38:54 +01:00
Maurice c1d413f31a docs(readme): set the sponsor logo right under its line and the server hint right under the button 2026-10-29 11:38:54 +01:00
Maurice 8316119293 docs(readme): thank dataforest cloud as infrastructure sponsor, drop the third showcase and the star history 2026-10-29 11:38:54 +01:00
Maurice 74abaa9d7c test(unsplash): check both refusals without awaiting in a loop 2026-10-28 16:30:54 +01:00
Maurice 091505d17c fix(unsplash): tell a server Unsplash turns away that the free key helps
Without an Access Key the image search goes through Unsplash's web endpoint,
which refuses datacenter addresses with a 401 or 403 or answers with a
challenge page. The search then said only "Unsplash search unavailable",
although an admin can already enter a free key in the settings. Such a
refusal now answers 502 with that hint and the code unsplash_key_needed, and
the cover search shows it in the user's language.

Closes #1449
2026-10-28 16:30:54 +01:00
Maurice aee275e2b0 fix(places): read an imported row for the dedup set in one helper
The candidate a GPX or KML row is checked as is built in lineCandidate, so
places.service.ts stays within its size baseline, and the atomic import test
imports the same Ridge line it later expects to be skipped.
2026-10-28 15:43:52 +01:00
Maurice ef86bf2660 fix(places): keep two different tracks of the same name apart on import
Two GPX tracks a watch named alike (two "Afternoon Hike"s) were taken for one:
the import compared a track only by its name, so the second was skipped as
already in the trip. A track now matches a track of that name only when it
also starts, ends and reaches as far as that one, to about a hundred metres,
so the same file imported again is still skipped. The GPX, KML and tour
imports and the tour preview all read the same rule.

Closes #2870
2026-10-28 15:43:52 +01:00
Maurice 0139c46fb4 test(i18n): count the new planned total key in the tours locales 2026-10-28 15:18:07 +01:00
Maurice a0a7fcdbb9 fix(tours): say why a tour over a day cannot be saved
A tour whose walking time and breaks came to more than 24 hours a stage
only greyed out Save, with nothing saying why. The planner now marks the
planned total and says what to do: make it a multi-day tour by ending a
stage at a stop, or enter a shorter planned total. The details section
opens by itself so the reason is in sight.

Closes #2869
2026-10-28 14:52:01 +01:00
Maurice 186dd94b61 fix(assistant): chat with an own server entered without /v1 at /v1
The assistant sent an own server's chat to the address as entered plus
/chat/completions. Ollama, entered as http://host:11434 the way its own docs
write it, answers that with a 404, so every message ended in "something went
wrong" while connecting had worked: the model list is found under the root
at /api/tags. A bare server address now chats under /v1, where Ollama, LM
Studio, vLLM, llama.cpp and LiteLLM all answer; an address with a path, such
as https://api.groq.com/openai/v1, is used as entered.
2026-10-28 12:17:54 +01:00
Maurice ad721a1ac6 test(studio): the auto layout lays out a long journey whole 2026-10-28 12:26:06 +01:00
Maurice 87441f269d fix(studio): lay out a book as long as its journey, with no page limit
A book held at most 150 spreads. The auto layout cut everything past that
without a word, so a long journey's book ended about three quarters of the
way through, with its last page and back cover gone, and adding, copying or
applying a designed page to a full book was refused.

The spread cap is gone from the book schema, the page operations, the
design composer and its apply, the assistant's book card and the design tool
contracts. A design's own page budget (pages.max) still bounds what it lays
out. The save route's 8 MB body still bounds a book's size, which at two to
five kilobytes an auto laid page is a few thousand pages.

The templated design digests move because their counter ids shift by two;
the pages, their order and their elements are unchanged.
2026-10-28 12:06:50 +01:00
github-actions[bot] a0943f8226 chore: bump version to 5.0.1 [skip ci] 2026-10-28 10:43:52 +00:00
Maurice 6741e8ca0d fix(help): give the close button a row of its own
A wider gutter left the button floating in the corner over the first card and the scrollbar. It now sits in its own row above the scrolling content, level with the Help title, and the content keeps symmetric padding.
2026-10-28 10:24:08 +01:00
Maurice c14f179e39 fix(help): keep the help center's content clear of its close button
The close button floats over the scrolling content, which only kept a 40px gutter on the end side while the button reaches 52px in, so the first card ran right up to it. The content now keeps a wider gutter on that side.
2026-10-28 10:24:08 +01:00
Maurice 3bfef73a46 refactor(maps): move the place clusters out of MapViewGL
The cluster source, its layers, the expansion zoom and the DOM bubbles
now live in placeClusterLayers.ts with their own tests, which brings
MapViewGL back under its size baseline instead of growing it. Lowers the
size and eslint baselines that this change made smaller.
2026-10-28 10:11:56 +01:00
Maurice 7e790c3fb7 fix(maps): draw the GL cluster bubbles like the Leaflet ones
The MapLibre and Mapbox maps painted their place clusters as a canvas
circle plus a symbol layer, so the count used whatever glyphs the map
style ships and the bubble had no hover. They are now DOM pins built by
the same markup and CSS as the Leaflet bubbles, with the same sizes,
font and hover scale. The canvas layers stay, invisible, as the hit
area the track and route layers check a click against.
2026-10-28 10:11:56 +01:00
Maurice 228db8541c chore(notices): show 5.0.1 in the release popup
The release popup still named 5.0.0 and linked the 5.0.0 release notes. It now names 5.0.1 and links its tag.
2026-10-28 09:52:18 +01:00
Maurice a729a6935f fix(journey): keep the photo picker's calendar inside the screen
The date picker of the external photo picker hung absolutely under its
trigger, inside a dialog that clips its content. The last week of a
six-week month fell below the dialog, and for the end date in the
right-hand column the calendar opened past the dialog's right edge, so
the last weekday and the next-month arrow could not be reached.

The calendar now opens in a portal, pinned to its trigger through
useAnchoredPosition: it flips above the trigger when below is too short,
scrolls when neither side holds it, and is kept inside the viewport
sideways.

Closes #2858
2026-10-28 09:25:53 +01:00
Maurice 6c0e39e287 fix(transit): name and record the backend that answered, not always Transitous
With Google as the transit provider, the result list still credited
Transitous, and a route added from the panel or through
create_transit_journey was saved with provider 'transitous'. The panel
now credits "Google Maps" (untranslated, as Google's terms ask) or
Transitous after the backend that answered and saves that one; the MCP
journey records the backend that plans routes for the caller, and
search_transit_routes reports it.
2026-10-27 21:30:18 +01:00
Maurice 22574a2270 fix(assistant): send Gemini's thought signatures back with its tool calls
Gemini 3 signs a tool call on its OpenAI-compatible endpoint
(extra_content.google.thought_signature) and refuses the next request
with a 400 when the call comes back without it, so every turn in which
the model called a tool failed. The Chat Completions client now keeps
the signature per call as the step's provider state and sends it back
with the call, to the model that signed it only.

An own server entered with Gemini's endpoint now also gets Gemini's
narrower tool schemas, which it refused before.
2026-10-27 21:06:29 +01:00
Maurice 1d880cb881 style(tours): format the route file reader 2026-10-27 15:29:00 +01:00
Maurice 3d076704f9 test: keep the new day-note and document-sync tests inside the lint and type gates 2026-10-27 15:16:50 +01:00
Maurice 1b0f043506 fix(roadtrip): stop pacing the joins between days that come back from the route cache
With connected days, every join between two days kept its one-request-a-second
slot even when the route came back from the device's cache, so a long road
trip still waited about a second per join on every open. A join answered from
the cache now gives its slot back; requests that go out stay spaced as before.

Refs #2794
2026-10-27 15:02:24 +01:00
Maurice c383b11648 fix(assistant): propose a route file's track from before Tours instead of skipping it
The Tours import takes over a same-named track a trip kept from 4.x, but a
route file attached in the assistant chat was read with the plain duplicate
rule only, so that track was reported as already in the trip and never
proposed. The read now leaves such a track to the import, which takes it
over as a tour like the Tours dialog does.

Refs #2810
2026-10-27 15:02:24 +01:00
Maurice fed564e7c0 fix(assistant): answer the model's own calls of server tools when a thread is replayed
remember_for_trip runs at once during a turn, and read_attached_file and
keep_attached_file are answered by the server, but all three are calls the
model made. Rebuilding a thread from its stored steps left them unanswered,
and ChatGPT, the OpenAI reasoning models and Anthropic replay their own record
of each step, call included, so the provider refused the conversation with a
400: the continuation after applied changes failed, and every later message of
the thread with it. A step that kept a note also sent an empty user message.
Every call the model made is now answered as in the live turn, the attached
file's outcome as the answer of its call; threads that broke this way work
again on their next message. A refusal's log line names the refused field's
path, never its message.

Closes #2845
2026-10-27 14:53:13 +01:00
Maurice 9e216f5ae4 fix(docsync): let the trip owner edit or remove a stored document-store connection
Once a store was connected for a trip, its address and login could only be
entered again after a sync run had been refused, and the stored connection
could not be removed at all: the server's DELETE route had no way in from the
app. A binding's settings now open the connection at any time, the folder
picker leads back to it before a folder is chosen, and the connection form
removes a stored connection after a confirmation, on the desktop panel and the
phone sheet alike. Removing it drops the trip's bindings through it; documents
stay on both sides.

Closes #2842
2026-10-27 14:45:17 +01:00
Maurice 0f0232491b fix(mcp): describe day-note title and body and allow the planner's 2000-character body
create_day_note and update_day_note described `time` as a clock label and capped
it at 250 characters, while the planner shows it as the Markdown body under the
title and allows 2000. The assistant put long text in the title and had to cut
notes it was asked to edit. Both tools now take text and time from the REST
contract with descriptions naming title and body, and an update still refuses an
empty title. The assistant's change card labels the two fields title and content
instead of text and time.

Closes #2840
2026-10-27 14:38:30 +01:00
Maurice 35fb59e64e chore(lint): lower the no-misused-promises baseline the packing fix brought down 2026-10-26 22:02:04 +01:00
Maurice ee3fff1dab fix(packing): Enter on the first item keeps the typing going in the add field
A new list starts with a '...' placeholder row. Typing the first item there
and pressing Enter saved it but left the cursor nowhere, while the "Add item"
field puts it straight back for the next item. Enter on a filled placeholder
row now opens the category's add field and focuses it. A blur still just
saves, so clicking elsewhere does not pull the focus back.

Closes #2825
2026-10-26 21:50:37 +01:00
Maurice 3bc75d2b1c fix(airports): keep airports OurAirports flags as having no scheduled service
The airport picker never offered Little Cayman (LYB): OurAirports marks it as
having no scheduled service although it has daily flights, and the build
dropped every airport on that flag. The same flag also hid Vestmannaeyjar,
Western Sydney and around 1,280 other large and medium airports with an IATA
code. The build now applies the flag to small airports only, where it keeps
private airstrips out.

The bundled list gains exactly those airports. Every entry it already carried
stays unchanged, because a fresh OurAirports export currently renames PBI to
DJT and drops nine other codes that saved bookings point at.

Closes #2820
2026-10-26 20:21:13 +01:00
Maurice 094426debb revert(places): keep the Tracks choice out of the pool while Tours is on, as B4A decided 2026-10-26 14:26:09 +01:00
Maurice 313198ba63 fix(places): read the dedup tracks off the dedup inputs, the places files stay within their size entries 2026-10-26 14:18:59 +01:00
Maurice e677d58f91 fix(places): a track is a duplicate only of a track, never of a sight of that name
A GPX or KML import skipped every row whose name was already a place of the
trip. A watch names a hike after where it went, so "Hokitika Gorge" was
refused as already in the trip because the gorge itself was a planned
place, with no track to show for it (#2810). A track now matches only the
tracks of that name; a pin still matches every place of its name.

The Places filter also hid its "Tracks" choice whenever Tours was on, on the
grounds that tracks are tours then. A track imported before Tours is a
plain place and stays in the list, so the choice now counts the tracks that
are no tour.
2026-10-26 14:08:56 +01:00
Maurice bc60aea839 test(studio): the book words read the new heading 2026-10-26 13:29:35 +01:00
Maurice b5cd951bd2 fix(studio): the page of stops without photos or words reads as along the way
Auto layout gathers journal entries with no photo and no text, one after the
other, onto one list page instead of giving each an empty page. Its heading
said "Stations" (German "Stationen"), which read as train stations and gave
no hint why those places were on it. It now says "Along the way" in every
language; each stop still carries its date.
2026-10-26 13:16:35 +01:00
Maurice fd818050bd fix(tours): pair the tracks to take over in the tours domain, off the places list 2026-10-26 12:47:09 +01:00
Maurice 0313597b10 fix(tours): read the tracks to take over through the typed query API, seed the tests through the factories 2026-10-26 12:35:19 +01:00
Maurice 2c867673bf test(tours): count the new preview key, lower the await-in-loop entry, type the adopt rows 2026-10-26 12:23:08 +01:00
Maurice 39ebf0976c fix(tours): a tour import takes over a track imported before Tours
A GPX track imported in 4.x is a plain place: a line, no tours row. It never
showed under Tours, and importing the same file there was refused as a
duplicate by its name, so there was no way to turn it into a tour (#2810).

The tour import now takes over a track of the trip that has the same name
and is no tour yet: the place keeps its name and line and gets the tours
row, with the recorded times the file adds. The preview says so instead of
"Already in the trip". New and taken-over tracks commit in one transaction.
2026-10-26 12:17:15 +01:00
Maurice 1ecc449f3e test(ai): fix the model-not-found pattern, guard the fetch mocks, and read the booking in an ORM context 2026-10-26 10:20:47 +01:00
Maurice b8990782cc fix(ai): connect and read bookings through any OpenAI-compatible own server
Connecting an own server asked Ollama's /api/tags and gave up on any answer
that was not a 404, and every failure, a refused key included, read as "not
responding". The model list is now looked for at /api/tags, at the address as
entered and at /v1/models, an answer only counts when it is a list, and a
refused key or a server that answered without a list say so (#2808).

AI parsing through an own server always called Ollama's /api/chat, which an
OpenAI-compatible server such as Groq answers with 404. A server without it
now gets the same extraction over /chat/completions at the address as
entered (#2806).
2026-10-26 10:20:46 +01:00
Maurice 8ba7add541 fix(plugins): give the runtime test's plugins table the accepted-range columns, and move the theme entry with the menu code 2026-10-26 09:49:22 +01:00
Maurice a3664e7117 fix(plugins): lower the unused-key entry the moved components reach again, and test the marked install 2026-10-26 09:49:22 +01:00
Maurice 64ed2175a5 fix(plugins): keep the API client and the panel test within their size entries, drop an unused import 2026-10-26 09:49:22 +01:00
Maurice 447ae54b93 fix(i18n): translate the plugin filter button where it read as English 2026-10-26 09:49:22 +01:00
Maurice 0067b0ff19 fix(plugins): let an admin run a plugin outside its TREK range, and tidy the plugins panel
TREK 5.0 left most registry plugins blocked because their authors still declare
"<5.0.0", and the only way past that was TREK_PLUGINS_IGNORE_TREK_RANGE for every
plugin at once. An admin can now install or switch on one such plugin after a
warning; the consent is kept on its row for that plugin version and TREK major,
so an update or a new major asks again. The environment switch works as before.

Discover marks a plugin not confirmed for this TREK on its card, and Install opens
the warning first. Admin > Plugins reads cleaner: one row of alike facts per plugin
with each state that needs attention on its own line, type, status and order behind
one Filter button, and the containment notes in a dialog behind the shield in the
header. The row, the filter menu and the dialog moved into their own files.
2026-10-26 09:49:22 +01:00
Maurice d589da59f2 fix(wanderer): find the author of an account whose one actor wanderer expands as an object
PocketBase answers a back-relation with a single record as the object itself,
not a list, so the user lookup failed as an answer TREK did not understand and
every search with it.

Closes #2799
2026-10-26 01:03:35 +01:00
Allan Braun 69ff295184 fix(assistant): answer Brazilian Portuguese users in Portuguese, not Breton
TREK's code for Brazilian Portuguese is 'br', which Intl reads as Breton.
The assistant passed it straight to Intl.DisplayNames for the system
prompt's answer language and to the card formatters, so the model was told
to answer in Breton. Convert TREK codes to their BCP-47 tag with
getIntlLanguage before handing them to Intl.

Closes #2800
2026-10-26 00:04:56 +01:00
Maurice d07c17ded6 fix(assistant): finish an OpenRouter sign-in whose code came back after a second question mark
The return address already carries a query, so a code appended with ? instead
of & ended up inside the ai marker and the page did nothing, without a word.
2026-10-25 19:36:00 +01:00
Maurice 44509723fd fix(bookings): keep the review's map status within the complexity and strict-null budgets
The status hides itself for a stay linked to a trip place instead of each form
branching around it, and a lookup takes a missing name.
2026-10-25 18:59:55 +01:00
Maurice b80fdf27f2 fix(bookings): show where an imported stay lands on the map before it is saved
An imported hotel was looked up once, by its name and its address together.
A booking site's romanized address (Agoda for a stay in Japan) often matches
nothing and sank the hotel's name with it, so the place was created without
coordinates and silently never showed on the map.

The review now finds the stay right away (name and address, then the name,
then the address) and says under the address whether it is on the map and
where, or that it is not yet, with a way to search again. Picking an address
suggestion sets the spot by hand. The save uses that spot, falls back the same
way when there is none, and a stay saved without a spot says so in a toast.
Desktop dialog and phone sheet share the lookup through useReservationForm.
2026-10-25 18:59:55 +01:00
Maurice a0b7b4d196 chore(lint): lower the complexity baseline the route store move took down 2026-10-25 18:59:49 +01:00
Maurice aa24a9eff7 fix(map): keep routed roads on the device so a whole-trip route opens drawn
With "Show whole trip" on, every leg of every day went to the router one at a
time, a second apart, and the route cache lived in memory only, so each visit
started over: a trip of a month took 30 to 45 seconds before its route was
drawn, on every open.

Routes the router answered are now also kept on the device (Dexie v14,
routeCache by RouteCalculator's cache key, a month at most, the oldest give way
past 3000). A leg found there needs no request and no pause, so a trip whose
legs did not change opens with its roads at once; only new or changed legs are
routed. Plugin routes stay in memory only, since they depend on trip and day.
The cache moves out of RouteCalculator into routeStore.ts.

Closes #2794
2026-10-25 18:59:49 +01:00
Maurice de5417f9f0 style(i18n): wrap the wanderer no_author line in the CJK locales 2026-10-25 18:59:43 +01:00
Maurice a48dd7645f fix(wanderer): say so when wanderer names no author for the token's account
A token whose account has no local ActivityPub author was reported as
invalid_response, which the settings show as 'The answer did not come from
wanderer', although wanderer had answered fine. It now has its own code,
no_author, with a message in every language that says what is missing.
2026-10-25 18:59:43 +01:00
Maurice 217a576e16 fix(wanderer): log why an answer from wanderer was not understood
A failed wanderer call kept its reason in WandererError.detail (the field that
did not match, or what came instead of JSON), and the client comment said it
stays in the log, but nothing wrote it anywhere. The log only had the generic
'wanderer answered with something TREK does not understand', so neither an
admin nor we could tell what wanderer had sent. Every refusal now logs its
code, message and detail.
2026-10-25 18:59:43 +01:00
Maurice f6976d5cec fix(assistant): send OpenAI reasoning models on an API key to the Responses API
gpt-5.6-sol, gpt-6.1-sol and the other reasoning models refuse function tools
on Chat Completions, so every assistant message on such a model came back as
HTTP 400. An OpenAI connection whose model is from the o-series or GPT-5 and
later now goes through the Responses API, the client a ChatGPT plan already
uses, with the key as its bearer token and no token refresh on a 401. Older
models and the -chat variants stay on Chat Completions.

Closes #2792
2026-10-25 16:55:39 +01:00
Maurice c1c2bbf146 fix(assistant): replay an Anthropic answer that thought with its thinking blocks
Sonnet 5.5 and Opus 5.5 think by default and write what they say between tool
calls into thinking blocks, which they want back unchanged with the rest of
the turn. The client dropped those blocks (and their signatures) and rebuilt
the turn from its text and calls, so after a tool result the next step came
back empty and the assistant gave up with empty_answer. A turn that thought
is now replayed block by block like one that searched the web; the cache mark
before the question skips a thinking block, and replayed blocks are copies so
a mark never sticks to the stored answer.

Closes #2789
2026-10-25 16:49:46 +01:00
Maurice c05349f7c4 fix(docker): let dumb-init forward the stop signal in the shipped compose file
The compose file drops every capability and adds back CHOWN, SETUID and
SETGID. dumb-init runs as root and node as the node user, and signalling a
process of another user needs KILL, so docker stop never reached node and
always ended in SIGKILL (exit 137). KILL only reaches processes inside the
container's own PID namespace. CI starts the image with the same set and now
checks that docker stop exits 0 after a logged SIGTERM.

Closes #2790
2026-10-25 16:47:22 +01:00
Maurice a5342df648 fix(assistant): list the admin model switch among the routes a managed install withholds
The boot check pins every @ManagedForbidden route; the new
AiAdminController.setModel was missing, so the server refused to start.
2026-10-25 16:29:31 +01:00
Maurice f44b143371 fix(assistant): send a pasted ChatGPT callback once, and only for this sign-in
The server takes a sign-in only once. The clipboard read on return and a paste
of one's own could send the same address twice within one render, and an
address left over from an earlier attempt used up the one in progress, so the
real address came back as 'took too long'.
2026-10-25 16:29:31 +01:00
Maurice d911122a37 fix(assistant): let an admin switch the model of a shared ChatGPT plan
The model picker in Admin > Addons did nothing for a ChatGPT connection: the
only way to change the instance's model was saving the connection again, which
needs an API key a plan does not have. A PATCH /api/admin/ai/connection/model
now switches it on its own, to a model the provider offers.

Closes #2783
2026-10-25 16:29:31 +01:00
Maurice a69eee191c test(plugins): let the instance-settings fixture run on TREK 5
The fixture plugin declared trek >=4.0.0 <5.0.0, so since the version bump to
5.0.0 the host refuses to spawn it and INS-012 fails on dev.
2026-10-25 15:59:59 +01:00
Maurice 90f31d3f07 fix(assistant): give the phone home chat its own m-root so it is not see-through
The home chat outside a trip mounts beside MobileShell, so none of the --m-*
tokens it paints with resolved and the dashboard showed through the chat.
2026-10-25 15:59:59 +01:00
Maurice d4da05bbc9 docs(readme): name the container and allow plain-HTTP login in the quickstart
docker logs trek found nothing because the run command gave no --name, and
over plain HTTP the browser dropped the Secure session cookie, so the printed
admin login did not stick.
2026-10-25 15:41:45 +01:00
github-actions[bot] bd1de9e1d5 chore: bump version to 5.0.0 [skip ci] 2026-10-25 11:07:55 +00:00
Maurice c33eb5b593 refactor(files): the provider marks and the map that keys them live apart
The marks module exports components only, so fast refresh keeps it whole and react-refresh no longer counts it; the map by provider id moves to documentProviderIconMap.ts, which every caller now imports.
2026-10-25 11:24:36 +01:00
Maurice dcfbf89234 fix(files): the provider marks are reached through their map only
The 4.3.3 release card that imported the five marks one by one is gone, so they stay inside the module and knip no longer counts them as unused exports.
2026-10-25 11:09:41 +01:00
Maurice 6b6d971f07 fix: the CI findings on the 5.0 release notes and the demo fixes
The overview of a trip's tours decides for itself when it shows, which keeps the planner page hook inside its complexity and length limits, and adds nothing to the camera's key while there is no tour to frame. The elevation helper names its empty case for the strict type check, the self-host wording exemption for the retired Dawarich card goes, the day plan test reads the wrapping pills, and the new tests drop their non-null assertions and loops.
2026-10-25 11:03:03 +01:00
Maurice 9fc98b5d49 feat(notices): the release notes present 5.0.0
The update dialog now shows the 5.0.0 headliners, Tours, TREK Studio, the assistant and help on every screen in 27 languages, each with a drawing of its own, translated into every language TREK ships. The 4.3.3 cards and their drawings are gone.
2026-10-25 10:51:57 +01:00
Maurice daaa6c425d fix(notices): a release's notes show to whoever dismissed them on its prerelease
Dismissals were compared with the prerelease part dropped, so a notice closed on 5.0.0-pre.2 counted as closed on 5.0.0 and the final release showed nothing to anyone who had tried a prerelease. Versions now compare in full.
2026-10-25 10:51:57 +01:00
Maurice b33ef6bec2 feat(admin): the AI assistant addon carries a beta badge 2026-10-25 10:51:57 +01:00
Maurice eb9f6a3438 feat(tours): the Tours map shows the trip's tours while none is open
The map stayed empty until a tour was opened. It now draws every tour shown in the list, line and marker as on the plan, frames them, and opens a tour from its marker.
2026-10-25 10:51:57 +01:00
Maurice 7823ab1550 fix(collab): What's next times a hotel by its check-in when the stop has no time
The day a stay begins, a hotel stop without a time of its own read TBD; it now shows the stay's check-in, as the road trip already times it.
2026-10-25 10:51:56 +01:00
Maurice 47fdd3588c fix(collab): a link keeps its glyph until its favicon has really loaded
A host that never answered, or answered with a blank pixel, left the chip with an empty square.
2026-10-25 10:51:56 +01:00
Maurice a9ebd2d1ac fix(collab): a poll option with more than three voters shows how many more 2026-10-25 10:51:56 +01:00
Maurice 9c32b42256 fix(planner): a tour's facts in the day plan wrap instead of being cut off
A tour row carries its time, duration and distance; in the narrow day column the last of them disappeared behind the fade.
2026-10-25 10:51:56 +01:00
Maurice 26611b46e4 fix(tours): elevation model jitter no longer counts as ascent
Ascent and descent were the raw sum of every height difference, so a flat walk across Venice (highest point 16 m) read 121 m of climbing. Heights now count once they move 5 m from the last one that counted; the server's stored figures and the planner's live analysis share the one function.
2026-10-25 10:51:56 +01:00
Maurice e3eec8d6d1 fix(budget): a custom split in a foreign currency adds up to what was paid
Each member's amount was converted and rounded to a cent on its own, so four 34,000 ISK quarters of a 136,000 ISK bill came to 997.52 EUR against the 997.51 the payer was credited. The balances ended a few cents off zero and settle-up had no payment for them. A split whose amounts match the payers in the bill's own currency is now converted as one set by largest remainder, in the settlement and in the per-person summary alike.
2026-10-25 10:51:56 +01:00
Maurice 2d398568da fix(assistant): no status request while neither AI addon is on
Every page of an install with the assistant and AI parsing off asked /api/ai/status and logged the 404 the addon guard answers. The store asks only while an addon opens the route and the install is not managed, and one hook on the desktop window and both phone screens asks again when the assistant or AI parsing is switched.
2026-10-25 09:48:37 +01:00
Maurice 4051af58a7 fix(studio): a rail label wraps between words, never inside one
The rail gave its labels 48px and let them break anywhere, so Elements read Element and s, and nine languages broke a word at the default text size. Labels wrap only between words now, and the rail grows to its longest word from 64px.
2026-10-25 09:46:40 +01:00
Maurice 3681db6cd4 fix(collections): the collection map opens on all of its places
The map framed its first view for the browser window instead of its own box and then skipped the fit, so a list of cafes across Europe opened on northern France. It now fits the places in its own box with room for the label bar and the controls, fits again when the set of places changes, keeps a view the user moved, opens a single place at town level and keeps a list across the date line on its short arc.
2026-10-25 03:12:19 +00:00
Maurice 44cefc2fe2 docs(wiki): pictures for the 5.0 pages that had none, and captions that match
Tours, TREK Studio, Road Trip, Trip Settings, the notifications inbox, the phone layout, Help Center, Passkeys and the admin pages for settings, storage and notifications get pictures where the reader needs them, and the captions of the recaptured pictures say what they now show.
2026-10-25 02:10:39 +00:00
Maurice 176bfaefe0 docs(wiki): every picture of the app recaptured on 5.0 with the new demo content
The 74 screenshots of TREK itself show the 5.0 look and the three demo trips, journeys and collections, all at 1600x1000 in the light theme. The Portainer pictures stay as they were.
2026-10-25 02:10:39 +00:00
Maurice 2372dde716 fix(demo): journey and collab uploads are refused for the demo account too
Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
2026-10-25 00:46:24 +00:00
Maurice 42ed858853 fix(demo): a demo instance keeps the files its baseline points at
The hourly reset puts only the database back, so a cover, photo, place image or trip file a visitor deleted in between was missing from the restored rows for good. On a demo instance StorageService.delete now leaves the content categories alone; the caches, the backups and the assistant's attachments are still deleted, and the orphan thumb sweep no longer counts a kept thumb as removed.
2026-10-25 00:46:24 +00:00
Maurice a9fb0fc475 feat(demo): the welcome dialog credits every photo of the demo content
A Photo credits link in the dialog's footer opens a second view with every photo: its title linking to its page on Wikimedia Commons, its author and its license linking to the deed. The list is its own chunk, loaded when a visitor opens it. The countdown test reads the clock the interval moved.
2026-10-24 23:22:15 +00:00
Maurice 5596355141 refactor(demo): the account tab and its phone screen share one demo lock
Both views had grown the same lock line in front of the same settings destructuring, which made the two a copy.
2026-10-24 23:22:15 +00:00
Maurice 9c83386655 test(demo): the OAuth demo lock cases take their controller from OauthModule 2026-10-24 23:22:15 +00:00
Maurice 98961dadd5 fix(demo): the addon policy names the legacy memories row, which stays off
The trip Photos addon of old releases is still seeded, switched off, and nothing reads it any more. The policy test now fails on no row, and its checks run without awaiting in a loop.
2026-10-24 23:22:15 +00:00
Maurice 7ada99acfc fix(demo): the demo's addon set and the demo account's settings are written without awaiting in a loop
One UPDATE per shelf and state and one upsert for the switches, inside the same transaction, and the demo account's settings in one upsert. The two new log lines go through the logger.
2026-10-24 23:22:15 +00:00
Maurice 2ac333f842 fix(demo): skip the example trips when the demo account owns its own
The seed put the example trips in whenever the admin owned none, so a demo
whose content belongs to the demo account got them back on every reopen,
the hourly reset included, and the next baseline saved them along.
2026-10-24 23:22:15 +00:00
Maurice 270a13ff60 feat(demo): the AI assistant, AI parsing and the MCP server stay off in the demo
They spend the instance's AI credit or hand tokens to a shared account, so
they belong to a self-hosted install, not to the public demo.
2026-10-24 23:22:15 +00:00
Maurice d800e64f93 feat(demo): lock the demo account's settings under a striped demo overlay
The demo account can look at its Account tab, API keys, MCP configuration,
own AI connection and language, but not change them, and the server already
refuses each of those writes. DemoLock renders such a section dimmed and out
of reach under diagonal stripes in theme tokens, with a small Demo mode badge
and one line saying why; useDemoAccountLock decides, on the desktop settings
and the phone screens alike (a new pair in feature-pairs.json).

Locked: the whole Account tab (profile, password, 2FA, passkeys, deletion),
API keys, the MCP card with its tokens and connected apps, connecting an own
AI model and the language picker, which shows English. The admin of a demo
instance sees everything unlocked, 2FA included, so the old instance wide
"Not available in demo mode" line under 2FA and its key are gone from all
locales.
2026-10-24 23:22:15 +00:00
Maurice 390135f3bc feat(demo): a new welcome dialog for the demo, in the 5.0 dialog style
The old popup was a narrow white card with its own copy of the texts in
eight languages, hex colours and a minute counter on the browser clock. The
new DemoWelcome is a wide dialog on DialogShell with theme tokens only: what
TREK is in one line, the hourly reset with a live countdown in the server's
time zone, the 5.0 highlights to try right away (trip planner with bookings
and costs, Tours with 3D terrain, TREK Studio photo books, live
collaboration, Journey, Atlas, Vacay, Collections, offline use), what the
demo leaves out and what the full version adds, and the link to the source.

Its strings are real i18n keys in a new demo domain file, translated into
all 27 locales. Both dashboards open it instead of the old DemoBanner, which
leaves the theme and format baselines with its file and the test non-null
assertions it held.
2026-10-24 23:22:15 +00:00
Maurice cad7d54e83 feat(shared): the demo account's addresses live in one place for both sides
The client is about to lock settings sections for the shared demo account,
and it has to ask the same question the server does. DEMO_ACCOUNT_EMAILS and
isDemoAccountEmail now sit in @trek/shared, and the server's DEMO_EMAILS is
built from them instead of spelling the two addresses a second time.
2026-10-24 23:22:15 +00:00
Maurice 2df677d065 refactor(demo): the addon states type names its shelves inline
The shelf shape was an exported interface nothing outside the repository
imports, which knip counts as an unused export.
2026-10-24 23:22:15 +00:00
Maurice 03493d4f79 feat(demo): a fixed addon set on every boot and after every hourly reset
A demo instance now runs with one explicit addon policy (demo-addon-policy.ts)
instead of whatever its admin last switched: every trip, global and
integration addon on, and off only what reaches a server of the visitor's
own, the five document sync providers, wanderer under Tours, the Immich and
Synology Photos providers, AirTrail and Dawarich. The switches under the
addons (Tours maps, piste routing, bag tracking, the collab features) are on.

The demo seed applies it, together with the demo account's English
language, on every boot and after every reopen, and the hourly reset applies
it once more after the baseline swap, in one transaction each. A test pins
that every seeded addon and provider is named in exactly one list, so a new
addon has to be classified before it ships.
2026-10-24 23:22:15 +00:00
Maurice eae74c5605 feat(demo): the shared demo account keeps its keys, account and language
Every visitor of a demo instance signs in as the same account, so one
visitor's API key, MCP token, OAuth client, passkey or new email address
became everybody's. These writes now answer 403 for the demo account through
refuseForDemoAccount in demo-write.ts: creating API keys, creating and
deleting MCP tokens, registering, rotating and deleting OAuth clients,
approving an app and ending a connected app, changing the name, address or
picture, and enrolling, renaming or deleting passkeys. Password, 2FA,
account deletion, sessions and the own AI connection were refused already.

The demo account's language is fixed to English: a single settings write of
the language answers unchanged, a bulk save drops it and keeps the rest. The
MCP settings tool is held back for the demo account by the tool gate, as
every write tool is. The admin of a demo instance and every real instance
are untouched.
2026-10-24 23:22:15 +00:00
Maurice ec486214f1 docs(wiki): the compose page names the storage seed file without a path the repo does not hold 2026-10-24 22:42:18 +00:00
Maurice d09a09ad52 Link the new wiki pages from the related sections of the pages around them 2026-10-24 22:42:18 +00:00
Maurice 8b41561db9 Reorder the wiki sidebar and link the new pages from Home
The sidebar now follows the way a user meets TREK: account, everyday use and
settings before planning, Tours beside Road trip and wanderer, Studio beside
Journey, and an AI and MCP section led by the assistant pages. Admin pages
follow the real tab order, with Backups and the Audit log at its end.
2026-10-24 22:42:18 +00:00
Maurice 73744203fb Add wiki pages for trip settings, the notifications inbox, the phone layout and wanderer
Each of these is a screen or a connection users meet in 5.0 that no page
explained: the trip's People, Sharing and Journal areas, the inbox page and
bell, what the phone layout offers and leaves to the desktop, and connecting
and watching a wanderer instance.
2026-10-24 22:42:18 +00:00
Maurice bc385f27dd Update the admin notifications, MCP, releases, audit and backup wiki pages
Add the paddle region alert, the to-do reminders, the new audit keys for Tours, paddle water, wanderer and AI, the release history paging and the backup hour hint. Dashes are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice 866b9e94af Update the admin categories, packing templates, plugins and storage wiki pages
Packing template deletes ask for confirmation, plugin versions can be changed and updates paused, and the toolbar buttons are icon buttons. Dashes are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice ed7c79bcb7 Rewrite the admin addons page for the current layout and the AI tile
The addons are searchable and filterable in cards per type, Tours is on by default and carries the wanderer, hiking map, cycling map and piste router switches, MCP, AI Parsing and the AI Assistant share one AI tile with a setup dialog, and the paddle water card loads regions automatically or on request. The overview lists the AI Assistant addon.
2026-10-24 22:42:18 +00:00
Maurice 1e4b618878 Update the admin users, defaults, settings and permissions wiki pages
Add the Reset 2FA action of the edit user dialog, the invite delete confirmation, the Link a journal permission and the phone admin sections. Replace dashes with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice 982861264e Add wiki pages for the AI assistant, AI connections and TREK Studio
The assistant, the shared AI connection and the Studio editor had no page of
their own; these describe them from the current screens and limits.
2026-10-24 22:42:18 +00:00
Maurice 6434fe7a3c Describe the update dialog, demo baseline card, phone admin and paddle panel in the admin overview 2026-10-24 22:42:18 +00:00
Maurice 70e07f1965 Update the demo, hardening, network and proxy wiki pages
Document the demo welcome popup, name wanderer and the AI assistant hosts in the SSRF guard lists, and list the paths a restricted reverse proxy must forward for MCP sign-in.
2026-10-24 22:42:18 +00:00
Maurice 87041c8842 Update the login, reset, 2FA, passkey and SSO wiki pages for the current screens
Describe the world map login panel, the first-run admin form, the plain HTTP cookie warning, the SSO error messages, the reset page states and the Reset 2FA action in the admin user dialog. Disabling 2FA accepts a backup code.
2026-10-24 22:42:18 +00:00
Maurice edb113f97f Update the settings wiki pages for the 5.0 tabs, cards and integrations
The Account tab is a stack of separate cards, the Integrations tab gained the wanderer connection and the AI connection, and the notification forms have test buttons. Dashes in the text are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice fcd5f5dbb6 Correct car and parking hand-over events, stop lengths and phone entry points on the calendar feeds page 2026-10-24 22:42:18 +00:00
Maurice 6849742d67 Update the PDF export page for Markdown in day pages and the current Studio layouts, themes and typefaces 2026-10-24 22:42:18 +00:00
Maurice 022e088da4 docs(wiki): point Tours and Dawarich at the Help Center and describe what the AI assistant does for tours and road trips 2026-10-24 22:42:18 +00:00
Maurice 2471fc5aa7 Document the Synology login fix, the connection test verdict and offline limits on the document sync page 2026-10-24 22:42:18 +00:00
Maurice fad301ef5e Replace dashes in the lines added to the install, update and contributing pages 2026-10-24 22:42:18 +00:00
Maurice 1c83bf78fa Update the Proxmox and Unraid install pages for first-start credentials, the pre-migrate copy and extra variables 2026-10-24 22:42:18 +00:00
Maurice a6cd4ffdff docs(wiki): add the winter layer of the ski piste router to the TREK Places API page, including what switching the index off does to ski tours 2026-10-24 22:42:18 +00:00
Maurice 988d27c39d Explain the stand-alone files page, offline limits and assistant file keeping on the files page 2026-10-24 22:42:18 +00:00
Maurice 62b954cffc Bring the developer setup and contributing pages up to the current scripts, CI gates and pull request rules 2026-10-24 22:42:18 +00:00
Maurice a2fed6afb8 Replace dashes in the lines touched by the wiki refresh 2026-10-24 22:42:18 +00:00
Maurice 97510e007e Add the phone layout, delete behaviour and the assistant to the to-do page 2026-10-24 22:42:18 +00:00
Maurice 36f81934aa Describe list reordering and the phone delete question in Packing Lists, and correct the template screenshot caption 2026-10-24 22:42:18 +00:00
Maurice 303bce431d docs(wiki): mention the SSO and passkey sign-in on the login page in the quick start 2026-10-24 22:42:18 +00:00
Maurice 73811e0bb7 Troubleshooting: Web Push, stale app versions, refused starts, AI parsing limits, place search fallback and offline sync failures 2026-10-24 22:42:18 +00:00
Maurice 0ca9f7241a Add PLACES_API_BASE, TREK_DB_FILE and an AI Assistant note to the variable reference and list the assistant connections in the key rotation page 2026-10-24 22:42:18 +00:00
Maurice bab4c7edcd docs(wiki): point Tags and Categories at the exact places to pick, create and filter a category 2026-10-24 22:42:18 +00:00
Maurice 166b0d8ab3 Update the Costs and Currencies pages for delete confirmation, offline receipts, decimals and the trip currency location 2026-10-24 22:42:18 +00:00
Maurice a5dbf6934c docs(wiki): add the recorded route, the Tours map layers and dragging markers to Map Features, and the routing engines to Map Settings and Route Optimization 2026-10-24 22:42:18 +00:00
Maurice d2bd6e7556 FAQ: answers for the AI assistant, Tours, Studio, push notifications, Dawarich, the help center and the health endpoint 2026-10-24 22:42:18 +00:00
Maurice 676d086ff2 Rewrite the AI booking import page around the shared AI connection and the Set up AI dialog 2026-10-24 22:42:18 +00:00
Maurice d8793f3d0b docs(wiki): describe choosing a place's picture in the editor and the photo gallery of the inspector 2026-10-24 22:42:18 +00:00
Maurice fcf4c6e736 Update the Updating page to 5.x examples and describe what 5.0 changes for an update 2026-10-24 22:42:18 +00:00
Maurice fa40dfba2c Help-Center: looping clips, the ? on tablets and the newer screens 2026-10-24 22:42:18 +00:00
Maurice 2a17227337 Rewrite the Portainer install page for the current compose file, 5.x tags, health status and the update switch 2026-10-24 22:42:17 +00:00
Maurice a5757dd59f Offline-Mode-and-PWA: desktop install, the book-fonts cache and the status pill 2026-10-24 22:42:17 +00:00
Maurice 2e10263155 docs(wiki): add tours to the day timeline of the day plan page 2026-10-24 22:42:17 +00:00
Maurice 7a7c66e801 Name the Vacay entitlement tiles and the poll countdown as the app spells them 2026-10-24 22:42:17 +00:00
Maurice a55728fe46 Collections: mention the List and Map switch on narrow windows and the assistant 2026-10-24 22:42:17 +00:00
Maurice 8bd5e428b4 docs(wiki): say only what the notification list does on the dashboard page, and drop dashes from the lines changed 2026-10-24 22:42:17 +00:00
Maurice 504b58ecf6 Correct the Helm env key note, add the Recreate update strategy and the pre-migrate copy 2026-10-24 22:42:17 +00:00
Maurice 0f05b0342c Photo-Providers: photos attach to journeys and Studio, not to trips 2026-10-24 22:42:17 +00:00
Maurice 7eea0e7669 Drop the Studio beta label and describe the Studio assistant and the trip's journal link in Journey-Journal 2026-10-24 22:42:17 +00:00
Maurice 0f88a74976 Bring the Docker and Docker Compose install pages to 5.x tags, first start behaviour, healthcheck, log rotation and optional variables 2026-10-24 22:42:17 +00:00
Maurice 9ffd5e5ffb docs(wiki): describe the cover photo search, the new start date step and offline edits of a trip, and the dashboard's hero badge, stat tiles and Idea cards 2026-10-24 22:42:17 +00:00
Maurice 00a326029b Add the wanderer hint and paddle region events and the full notifications page to Notifications 2026-10-24 22:42:17 +00:00
Maurice a47b35b965 Name the Upload plugin button, document dev link in the plugin panel and update the version example in the public API page 2026-10-24 22:42:17 +00:00
Maurice fcfcf2ff17 Document the end time hint, offline saving and the assistant on the bookings, transport and stay pages 2026-10-24 22:42:17 +00:00
Maurice 016567dd9a Document the trip journal link tools, the city walk check-off tool, metadata-document clients and the AI Assistant on the MCP pages 2026-10-24 22:42:17 +00:00
Maurice b64dd700a8 docs(wiki): name Tours, the AI Assistant, Trip settings and the notifications inbox on Home, and describe the planner's top bar and its Tours and Journal tabs 2026-10-24 22:42:17 +00:00
Maurice b0b6f5872f Wiki: name Tours, the AI Assistant, Trip settings and the notifications inbox on Home, and describe the planner's top bar and its Tours and Journal tabs 2026-10-24 22:42:17 +00:00
Maurice e119a2c936 chore(maps): the map test's size baseline drops to the 2919 lines it holds 2026-10-24 22:41:56 +00:00
Maurice 45ae8b5af2 fix(maps): narrow the PDF map container and assert the 3D toast by its key
The offscreen container is built inside buildGlMap's closure, where the
let lost its narrowing; a const of its own keeps it an HTMLDivElement.
The stage tests hand the stage a t that returns the key, so the toast
reads map.webgl2.terrain there. One floating promise fewer lowers the
eslint baseline to 164.
2026-10-24 22:41:56 +00:00
Maurice e28bd2fc99 fix(maps): draw with Leaflet wherever WebGL2 is missing
MapLibre 6 and Mapbox GL 3 both throw out of their Map constructor without
WebGL2. Every place that builds a GL map now asks one helper first,
components/Map/glSupport.ts, and a browser without WebGL2 gets the Leaflet
map with the user's tiles instead of a map that fails to start:

- the trip map (MapViewAuto) and the journey maps (JourneyMapAuto) pick
  their renderer through useGlRenderer, which folds in the Mapbox token
  rule they each had;
- the settings preview, desktop and phone, shows the Leaflet preview
  through useMapSettingsForm and says why under it;
- the tour planner keeps the 2D map when 3D is asked for and says that
  3D needs WebGL2;
- the PDF route map and the Leaflet vector basemap already fell back and
  now report a GPU failure too.

Behind the probe, buildGlMap wraps each constructor (MapViewGL,
JourneyMapGL, the settings preview, the terrain map, the PDF map). A GPU
that claims WebGL2 and still refuses the context is reported through
utils/webgl, which turns the answer to no and tells every mounted map,
so they switch to Leaflet without a reload; any other error is still
thrown. Two new strings, map.webgl2.fallback and map.webgl2.terrain, in
all 27 locales.
2026-10-24 22:41:56 +00:00
Maurice 1696162d22 fix(deps): maplibre-gl 6.15.0 past the attribution sanitizer advisory
GHSA-jrc7-96c5-q579 covers every maplibre-gl up to 6.4.0 and has no 5.x
release, so npm audit kept flagging the patched 5.24.0. Moving to 6.15.0
retires the local backport: patch-maplibre.mjs and its postinstall,
predev and prebuild hooks go, along with the Dockerfile copy and its
format-baseline entry. 6.x sanitizes attributions against an allow list.

What the major changes for TREK:
- ESM only, no default export: the engine module imports the namespace
  and hands that out as its default, so every caller stays as it was.
- The worker is no longer inlined, and inside a bundle MapLibre cannot
  find it next to its own module. The engine sets it from Vite's
  ?worker&url build before any map, the Leaflet bridge's included.
- WebGL2 only: the probe asks for webgl2 alone, so a WebGL1-only browser
  gets the raster basemap instead of a GPUInitializationError.
- 6.9 shapes right-to-left text itself and deprecated setRTLTextPlugin;
  the old plugin would replace that and could only run through eval in
  the module worker, so MapLibre no longer registers it. Mapbox does.

The lockfile moves maplibre-gl and its own dependencies only; the
Leaflet bridge already allows ^6.0.0 as its peer.
2026-10-24 22:41:56 +00:00
Maurice f375a2f3cc fix(collab): a quoted message wraps to two lines instead of pushing its bubble out of the chat
The quote inside a reply sat on one line with an ellipsis, and that unbroken
line set the bubble's minimum width, so a long quote pushed the bubble past
the edge of the chat and cut its words off. It wraps now and is cut after two
lines, on desktop and on the phone.
2026-10-25 00:21:06 +02:00
Maurice 53bd21a7d7 test(assistant): a changed journal entry can be undone
update_journey_entry answers the fields it replaced, so its card is
undoable now, as JRN-015 already checks for the undo itself.
2026-10-24 22:54:46 +02:00
Maurice 67d8f22622 test(assistant): the catalog check lists the Dawarich tools too
A stay taken into a journal now has a card of its own, so ASTTOOL-001
looks for its tool; Dawarich's tools are listed only with its addon on,
which the test now turns on beside the others.
2026-10-24 22:54:46 +02:00
Maurice 8433251c86 fix(assistant): the Studio's empty chat speaks of the book again
The welcome chose the journal's words whenever the Studio had not
registered as the chat's host, which also hit the Studio's own panel
before (or without) that registration. A journey's chat now speaks of the
journal only where the window or the phone's chat screen say they show it
for its journal; everywhere else, the Studio included, it stays the book's.

AssistantWindow had gone past the complexity limit choosing between the
planner's trip, the journal on screen, a picked chat and the home chat;
that choice now lives in windowChat.
2026-10-24 22:54:46 +02:00
Maurice 13449c3338 fix(assistant): no awaits in loops and no value only used as a type in the journal code
The journal header's tool list was a value read only for its type, so it is
a union now. A journey's session reads the entries a call names side by side,
the photo announcements go out together, and a photo move links its photos
one after another through a promise chain, so each still takes the next
place among the target's photos.
2026-10-24 22:54:46 +02:00
Maurice 35c3ab4bd7 fix(assistant): a journey's session no longer attaches delete_journey
isHeldInJourney took every tool of the journey's controllers from the Studio
side, delete_journey among them. The Studio never offered it (its writes are
the book's), so leaving it unattached changes nothing the model sees and
keeps the denial in one place.
2026-10-24 22:54:46 +02:00
Maurice 7356de5124 fix(journey): give the patched column shapes an index signature presenceSet accepts
presenceSet takes a Record<string, unknown>; an interface has no implicit
index signature, so the two column shapes are type aliases instead.
2026-10-24 22:54:46 +02:00
Maurice 8a2cb10f7b fix(assistant): tighten the journal cards' types and keep their helpers private
A Dawarich stay's row read fields one side of a union did not have, the
photo cards narrowed an entry's day through an optional chain, and the
settings service cast a journey row straight to a record. Helpers only
their own file reads are no longer exported, and the photo tests seed
their rows with insertRow like the other journey suites.
2026-10-24 22:54:45 +02:00
Maurice 0050072b76 feat(assistant): photo changes of a journal get their own card and ways back
The four new photo link tools join the journal's tools: the journey chat
offers them to editors, the trip chat loads them with the journey group and
the home chat keeps none. A journeyPhotos card shows the photos as
thumbnails with the entries they leave and reach, the first one marked as
the cover in a new order, and a caption old to new; a library add shows the
photos once they landed. A linked photo comes off again, an unlinked one
goes back, moved photos go back and a caption is written back, each while
nothing changed since. A new order of an entry's photos and a library add
have no way back yet. The journal prompt names the photo tools.
2026-10-24 22:54:45 +02:00
Maurice da43a81d26 docs(wiki): the journey MCP tools that answer what they replaced, and the photo link tools
update_journey takes show_trip_tracks and cover_gradient and answers
previous, update_journey_entry answers previous, reorder_journey_entries
answers previous_order, and the four photo link tools are listed.
2026-10-24 22:54:45 +02:00
Maurice a955328070 feat(journey): photo links tell every tab, move between entries, and gain MCP tools
Linking a gallery photo to an entry and taking it off told nobody, so other
tabs, and the journey page after an assistant change, stayed as they were.
Both now go through a photo links service that broadcasts
journey:entry:updated after the write, with the asking socket left out on
REST; a caption change through PATCH photos/:id broadcasts journey:updated
the same way. The service also moves photos from one entry to another of
the same journey in one transaction, as POST
/api/journeys/entries/:entryId/photos/move, with a strict contract. The MCP
tools link_journey_photo, unlink_journey_photo, move_journey_photos and
set_journey_photo_caption call the same methods; the caption tool answers
the caption it replaced.
2026-10-24 22:54:45 +02:00
Maurice 53c05ab33b feat(assistant): entry cards show tags, verdict and order, and changes can be taken back
The update card silently left out the tags and the pros and cons it was
about to write, so the user approved a change the card did not show. An
entry now shows its first tags and how its verdict leans, and an update
names the tags, each side of the verdict and the coordinates it changes.
A new order of the entries is drawn as the entries that move, in their new
places. A changed entry is written back from what the tool answered it
replaced, and a reorder is taken back to the order before, each while
nothing changed since. A Dawarich stay taken in as an entry gets the
entry card; it has no way back, since deleting the entry would leave the
stay marked as taken in.
2026-10-24 22:54:45 +02:00
Maurice 5160bd23d5 feat(journey): entry updates and reorders answer what they replaced
update_journey_entry now answers previous, the entry's earlier values of
exactly the fields it names (a suggestion written up goes back to being
one), and reorder_journey_entries answers previous_order, the ids in the
order they stood in before. Both come from the service the REST routes
use, which now builds an entry's patch in one pure helper; the routes'
bodies are unchanged. The tools' inline zod moves into strict schemas in
@trek/shared.
2026-10-24 22:54:44 +02:00
Maurice 48ff1b3a98 feat(assistant): curated cards and undo for the changes of a journal itself
Starting a journal, changing its settings, linking or unlinking a trip,
bringing back its suggestions, the user's own view of it and deleting it
fell back to the generic card, which names ids and offers no way back.
They now share a journeyHeader card: the journal with its trips, the one
the change links or unlinks marked, what a new journal brings in and what
a deletion or an unlink takes, and each setting old to new in the
journal's own words. A new journal is deleted again while nothing was
written, added or laid out in it, changed settings are written back from
what the tool answered, a linked trip is unlinked by its owner while none
of its suggestions was written up, and the own switch flips back.
2026-10-24 22:54:44 +02:00
Maurice cf35dc7afd feat(journey): update_journey takes every setting REST does and answers what it replaced
The MCP tool lacked show_trip_tracks and cover_gradient, which PATCH
/api/journeys/:id has always taken. Both now read the input through one
helper, and the tool goes through a small settings service that writes the
same patch in a transaction with the stored values it replaces, answered
as previous, so a change can be written back. The subtitle and the cover
gradient take null to clear them. The tool's inline zod moves into strict
schemas in @trek/shared; the REST route and its body are unchanged.
2026-10-24 22:54:43 +02:00
Maurice 4b3340546f feat(assistant): the journey page opens the journal's chat on desktop and phone
On /journey/:id the assistant window and the phone's assistant screen now
open the journey's conversation, the one TREK Studio holds too, instead of
the home chat; the Studio route keeps its own column. A turn sent from a
chat the Studio does not host names the journal page, a hosted one the
Studio, so the server offers the right tools. A journal card from the home
chat opens that conversation in place on the desktop and the journal page
on the phone, and the empty chat asks about the journal rather than the
book.
2026-10-24 22:54:43 +02:00
Maurice 1d9648267b feat(assistant): the home chat starts a journal and hands a journal's questions to its page
Asked on /journey to create "Japan 2026", the home chat had nothing to
offer: it keeps no write that names a trip or a journey. It now keeps
create_journey, whose trips the tool checks one by one, and delete_journey,
which the journey's own chat leaves out because the conversation goes
with it. For anything inside a journal it offers open_journey_chat, the
twin of open_trip_chat: the journal is read from the user's own journals
and shown as a card whose button goes on in the journal's chat with the
question waiting there. The trip and journal cards share their question
and button.
2026-10-24 22:54:43 +02:00
Maurice c0ec8e567b feat(assistant): the journey page gets a journal chat that offers its tools by role
Asked on a journey page to write an entry, the assistant said it could not:
the only journey chat was the Studio's, which keeps the journey's writes
out. A journey session now holds what either page offers and shows the
model only the asking page's tools. The journal page offers a viewer the
reads, an editor the entry, photo, recorded day and day import writes, and
the owner also the journey's own settings and trips; delete_journey stays
out of the journey's own chat. A few reads that name no journey come along
(place search, the photo libraries, Dawarich stays), the Dawarich journal
entry is pinned through its journalId, and an entry an argument names must
belong to the conversation's journey before the tool runs. The turn reads a
journal card and instructions of its own; the Studio keeps exactly what it
offered before.
2026-10-24 22:54:42 +02:00
Maurice 2508fd8624 feat(assistant): a journey's chat request names the page it is asked from
The journey's assistant lives in TREK Studio today, and the journal page is
about to get one of its own over the same threads. The chat and decision
bodies take an optional surface, studio or journal, and the journey scope
carries it to the service; left out it reads as the Studio, so today's
client is unchanged. The scope key ignores it, so both pages keep one
thread list per journey.
2026-10-24 22:54:42 +02:00
Maurice de497d15a4 test(assistant): the attachments repository on its own, every read, write and sweep step
The repositories are held to 99 percent of their statements, and the new
attachments repository was reached only through its services' mocks.
2026-10-24 22:32:48 +02:00
Maurice 05666295e0 test(assistant): every keep proposal in the attachments e2e takes a call id of its own
The refusal test proposed two keeps in one thread under the same call id, so
the second decision met the first call, already decided, and no refusal came
back. Each proposal now has its own id, and the refused right is set after the
proposal so it is the apply that refuses.
2026-10-24 22:32:48 +02:00
Maurice 62da83f5f7 test(assistant): the keep test reads the createFile call through a typed tuple
The mock's call list types as an empty tuple, so indexing its fourth argument
failed typecheck:tests.
2026-10-24 22:32:48 +02:00
Maurice b333910533 test(assistant): the attachments e2e provider answers the model list a saved connection asks for
Saving the custom connection lists the server's models first, and the fake
provider answered that with a 404, so the setup got 502. It answers /api/tags
the way the other assistant suites' fake provider does.
2026-10-24 22:32:48 +02:00
Maurice 2d1da19152 fix(assistant): the step that keeps an attached file's bytes is called stash
lint:test-sql counts every .prepare( under tests/ as a raw statement, and the
attachments service's prepare() was called five times in its unit test. The
test holds no SQL; the method takes a name that does not read as one.
2026-10-24 22:32:48 +02:00
Maurice e7ac46c756 fix(assistant): the keys of the attached-file answer are named where lint:i18n-keys sees them
The server built the answer through words.text(), words.count() and maps of
plain strings, so the check read all 13 assistant.attach keys as unused. The
helper's lookup is t() now (a numeric count picks the plural form, as the
client's does) and the chip and reason maps hold their keys under *Key
properties.
2026-10-24 22:32:48 +02:00
Maurice f1a2174f96 chore(assistant): the attachment work exports only what other files use
Types that only their own file reads lose their export, so knip's
unused-exports ratchet does not grow with the attachment work.
2026-10-24 22:32:48 +02:00
Maurice 89c33ac3f4 feat(assistant): an attached file can be kept in Files, on its own or with a booking or an expense
The chips offered to keep an attached file in Files, but nothing could do it:
the assistant's upload_trip_file stays denied, and the file never reached
Files. A new server write, keep_attached_file, is offered to the model where
the thread holds an attachment and held as a change card naming the file and
the booking, expense or place it goes with.

Applied, it runs the checks of a Files upload with the user's rights (the
demo block, the trip, file_upload and file_edit for a link, the instance's
file types, every linked id on the same trip), copies the bytes to a trip
file of their own, and writes the trip file, its expense link and the
attachment's pointer to it in one transaction; the members' planners get
file:created after it. A second keep of the same attachment answers the file
kept the first time. FilesService gains storeBytes and discardBytes so the
storage write stays out of that transaction.

The receipt opens the Files tab; the card's verb, its "Attached to" row and
the receipt's open label are new keys in all 27 locales. Undo is not offered:
no tool takes a trip file back yet.
2026-10-24 22:32:48 +02:00
Maurice aeda658f18 feat(assistant): the model reads an attached file once the user agrees
The assistant could not read a file the user attached: the bytes were gone
after the request, the thread only held the file's name, and read_trip_file
stays denied because it would send any document of the trip to the provider.

A new server tool, read_attached_file, reads only the files attached in this
conversation by this user; any other id is answered "No such attachment in
this conversation.", whoever it belongs to. It is offered in a trip turn whose
thread holds a file with text. The first read of a file is held as a change
card that names the file and the model and host its text would go to; once
the user agrees, the agreement is kept on the attachment and later reads run
at once. The text reaches the model through the history and, for a later
read, as a tool answer only the model gets (AgentToolRun.modelText): no message
part, stream chunk, shared answer or client store ever carries it.

The model is told each kept file's attachment id, and the standing
instructions say to read the file when the user wants to work with it, to
propose a booking found in it through create_reservation and then to offer
keeping the file with it. The card's verb, its "Sent to" row and the step's
name are new keys in all 27 locales.
2026-10-24 22:32:47 +02:00
Maurice 2f3a0219cd feat(assistant): every attached booking file gets an answer with what to do next
A booking file without a booking ended the answer on the error "No booking
found in this file." with no action, and the user was left to type. Now the
server answers every booking file itself, without a model: one line that
names the file and what TREK found in it, or that it found nothing, why, and
asks what to do with the file. Under it come up to three chips, picked from
what the user may do on the trip and what the file allows: keep it in Files,
make a booking from its text, attach it to a booking or an expense, ask what
is in it, or keep it with the booking just found. A chip sends its words as
an ordinary chat message.

The line and the chips are written in the language of the request from new
assistant.attach.* keys in all 27 locales, through a small helper the sports
proposals now share. The empty answer finishes as stop; no_bookings_found
stays in the contract for the threads older builds wrote. Route files keep
their answer as it was.
2026-10-24 22:32:47 +02:00
Maurice 41fb5759e2 feat(assistant): an attached file without a booking says why
The chat's booking reader kept only the bookings of the import preview and
dropped its warnings, so "AI reading is not set up", "the AI failed",
"a scan without text" and "nothing in it" all came out as the same
no_bookings_found. The reader now reads the reason from the preview (the mode
it ran in and the AI fallback's warnings) and keeps it in the stored read
step, and the note the model is sent names it.
2026-10-24 22:32:47 +02:00
Maurice 6a77496438 feat(assistant): an attached file stays with its conversation
A file attached in the trip chat lived only as a buffer for the one request
that read it, so the assistant could not look at it again and nothing could
keep it in Files. Now its bytes go to storage before the turn writes anything
(under the trip files' category, which is never served by path) and a row in
the new assistant_attachments table keeps its name, type, size, hash and the
text TREK read out of it, written in the turn's opening transaction. The
message's attachment part carries the row's id; old messages without one still
read.

The text is read the way the AI import reads it, through a new public
LlmParseService.readText, and a file whose text cannot be read is kept all the
same. A storage that refuses the bytes refuses the turn before any row exists.
Deleting a thread takes its files along, and the nightly retention sweep drops
files older than seven days and bytes no row names any more.
2026-10-24 22:32:47 +02:00
Maurice 487f8dd886 refactor(assistant): the answer to an attached file is a provider of its own
AssistantService sat three lines under the size limit, and the attachment
work ahead grows exactly this answer. Moving it into AssistantFileAnswers
leaves the rows and their transactions with the service, which hands the
answer the two ways it writes its first row. Nothing the user sees changes.
2026-10-24 22:32:47 +02:00
Maurice 09f286fbfe docs(readme): the feature list as 5.0 has it
Every count checked against the code (410 MCP tools, 35 scopes in 17 groups,
18 booking types, 17 trip permissions, 66 plugin permissions), and the parts
5.0 adds get their own place: Tours, TREK Studio, the AI assistant, the help
center, Road trip, Dawarich and the linked journal. Most lines are shorter.
2026-10-24 22:30:55 +02:00
Maurice 4cc62f4c4e test(journey): the design_book request in the MCP test takes the journey beside the parsed directives 2026-10-24 20:58:49 +02:00
Maurice cb34c46226 fix(studio): CI on the templated auto layout
The rhythm test holds the picker to what it does now: it gives the rhythm up
only where no template of the book's styles frames every photo and keeps it.
A design applied over a stored book keeps that book's theme, as the merge has
always done, so the end to end test checks the pages instead. The MCP test
builds its request through the shared schema, a travel mark's frame is read
with its sides spelled out for the client's looser types, and the client's
react-hooks/refs baseline drops to the 555 it holds now.
2026-10-24 20:54:16 +02:00
Maurice e777118272 feat(studio): auto layout lists bare stops on a words-only template of the book's style
Stops nobody wrote about or photographed were set as a small list on a plain
spread, the old composed page. They now take a words-only template of the
book's styles: the list's label as its title, the stops as its story grouped
by day, as many as its story frame holds, and the day in the template's date
where all of them share one. The words go to their roles by name, since a
short list that opens on a date would otherwise read as a date line. The
composed list stays as the fallback where no template takes it.
2026-10-24 20:28:37 +02:00
Maurice 3f2da7a867 test(studio): repin the templated parity digests on top of the design figure and riso cover fixes 2026-10-24 20:14:06 +02:00
Maurice 9c3649f011 fix(studio): auto layout keeps every page in the book's own styles and every photo in a frame
The picker scored all 108 templates at once, so a page whose photos the own
pool could not frame borrowed a template of another style (a night film page
in a poster pop book). It now goes in tiers: the theme's archetype and its kin
with a frame for every photo, then the same with the rhythm given up for that
page, then another archetype, and only then a photo left in the tray. The
photos of a chapter are cut into the counts the own pool has, and only where
those leave a photo over into counts another archetype has, at a price. Riso
joins poster pop's kin for its one photo story page.

Across the 17 themes and the nine fixture journeys no photo waits in the tray,
no page is laid in another style and no page has a severe finding.
2026-10-24 20:13:44 +02:00
Maurice 73fdbdc0c3 feat(studio): Auto layout lays the book on the designed pages
"The whole book" now lays the book on the designed templates through
autoDesignedBook, in the book's own page format and theme (a recoloured
one of its own included), its title kept and every page holding a locked
element kept; each press takes a new seed, so a second press gives another
book, one undo away as before. "This spread" lays the page on another
design than the one on screen and its neighbours, its id kept, and now
works on the cover, the summary and the countries too, so the menu offers
it wherever the auto layout knows the page. Loose gallery photos reach the
layout with the day they were taken, which it shares them out by. The
menu's two hints say what the button now does, in every language. Nothing
here places the classic summary, countries or marks any more.
2026-10-24 20:13:44 +02:00
Maurice 0879b5aecc feat(shared): the Studio's auto layout entry points
auto/auto-designed.ts holds what the Studio's Auto layout menu calls.
autoDesignedBook lays the whole book on the designed templates in the
book's own theme object (a recoloured one of its own included, the calm
fabric-classic theme for a book without one), over the stored book, its
page format kept and every page holding a locked element kept; another
seed lays another book. autoRelayoutSpread lays one page again around its
locks, its id kept: an entry's page on the best template other than the
one on screen and its neighbours', avoiding one that would cut its story
where it can; the cover on the next cover template of its archetype and
then of its kin; the summary and the countries in their next look, an old
classic summary included. Pages the engine did not make are left alone,
as before. autoRelayoutKind says which pages those are.

designBook's templated path is split so the relayout reads the same
setting (templatedSetting) as the whole book, and the page layer is
shared (layEntryPage); the parity digests are unchanged by it. buildBook
and relayoutSpread stay exported and unchanged.
2026-10-24 20:13:44 +02:00
Maurice 9729b0b575 feat(server): design_book offers the designed templates
The design_book tool says in one sentence that layouts: 'templates' lays
every page on the Studio's designed templates in the archetype of the
book's theme, as the Studio's Auto layout does, and that each plan row then
names its template; style classic alone stays the old built-in Auto
layout. The field comes from the directives contract in @trek/shared, so
the tool's schema takes it without a zod line of its own, and the answer's
plan row gains an optional template. The REST draft routes take no
directives, so nothing changes there. Without the field the answer is the
same as before, no row naming a template.
2026-10-24 20:13:43 +02:00
Maurice 35acdb8ba1 feat(shared): design_book can lay the book on the designed templates
A new optional directive, layouts: 'templates', lays every entry on the
decorated spreads of the book theme's archetype and its kin
(design-templated.ts). An entry's photos are cut into page-sized parts that
leave no frame empty, at most twelve of them, the first part carrying its
story; each part takes the template that suits it best, poured from a page
of the entry's photos and story so the title, place, date and story stay
bound to the journal. A story longer than its frame is cut at a sentence
and goes on over a page of words. Date marks are set as stamps, never in
the classic styles. Loose gallery photos go to the entry they are linked
to, then to the entry of their day, then to the entry with the fewest
(shareGalleryByDay, beside distributeGallery, which stays as it is); what
no entry has room for closes the book on up to three pages of more
moments, and anything past that is said to be left out, so no photo is
dropped without a word.

The rest is the composer's: scope, the photo picker, the page budget, the
stored pages kept, repair and findings. A book can bring its own theme
object (BookDesignInput.theme), recoloured or not, and it is kept whole.
Plan rows name the template a page was laid on. Without the directive
every design is composed exactly as before, and classic is still buildBook.

AUTODSN-001..015 cover the rules, and AUTODSN-020 pins a digest of every
page on the auto layout's parity journeys and a fourth one in three themes.
design-templated.parity.json was computed with the spec's own canonical form
and digest over these inputs, run through tsx; CI checks it from here on.
2026-10-24 20:13:43 +02:00
Maurice a19fdf6161 feat(shared): framing pages from the new travel looks
design-templated-framing.ts builds the pages around the chapters of a book
laid on the designed templates. The cover is laid on one of the theme
archetype's cover templates, poured from a page whose title and subtitle
are bound to the journey, so the binding survives; an archetype with no
cover for the photos there are has its cover composed as before. The
journey at a glance is its map with a photo at every stop beside the
figures as a poster (tiles for the collage and grid books, a sign for the
postcard ones), a ticket stub of how far and how long, and the furthest
point on an odometer. The countries are one country's map cut to its shape
beside its stamp, a card each for up to twelve, one panel of stamps past
that. The back cover adds a band of days, places and photos and a row of
country stamps. A page of words, where no template takes a story, sets it
beside the stop's map, its day as a stamp and its country as a tag. None of
these sets a summary, a country or a mark in the classic look.
2026-10-24 20:13:43 +02:00
Maurice 813dd7a7eb feat(shared): pick a decorated spread for a page's photos and words
design-template-pick.ts says which decorated template a page of the auto
layout is laid on. Every built-in theme belongs to an archetype
(THEME_ARCHETYPE, the five new themes to their own five), and a book picks
from that archetype's spreads and from those of the archetypes that sit
well beside it (ARCHETYPE_KIN). A template that would leave a photo frame
empty, lose the story, draw a map with nothing to draw, repeat the page
before or follow a busy page loudly is never a candidate; of the rest the
best fit wins: the photos all used, the theme's own archetype, the story
fitting its frame (bodyCapacity measures each frame once per format and
face), the first frame the shape of the first photo, not used a few pages
back, and calm where the book should breathe. Equal ones are chosen by the
design's seed. chunkPhotos cuts an entry's photos into page-sized parts
that leave no frame empty, and pickCoverTemplate does the same for the
cover.
2026-10-24 20:13:43 +02:00
Maurice d51b9de2b0 feat(shared): a laid page knows which decorated template it is
matchDecoratedTemplate reads a page's elements by kind and frame, rounded
to the millimetre, and compares them with every template laid on the same
page format: a template is the page's when nearly all of its design is
there and it explains most of the page, and of those the one explaining the
most wins. A page rearranged by hand reads as none. The auto layout's
"this spread" uses it to offer a template other than the one on screen.
2026-10-24 20:13:43 +02:00
Maurice 6254510e0f refactor(shared): the travel looks are built in shared
The factories behind the Travel panel's cards (a summary as a poster, a
ticket or a sign, a figure on a medal or an odometer, a map of the journey
or of one country, a mark as a stamp or a tag, a country as a stamp, a visa
or a postcard, each dressed in the book's theme) move to
book/travel/book-travel-looks.ts, so the auto layout can build its framing
pages from the same looks. Where an element lands stays the caller's: the
panel still centres it on the spread on screen, through the same wrapper and
the same API, and a designed page names its frame. A mark can now also take
an entry's value rather than the journey's.
2026-10-24 20:13:43 +02:00
Maurice c525b2f84c fix(studio): the riso cover's year band deepens a mid-tone ink so the year reads
In a theme whose second ink is a mid tone (riviera's olive), neither white nor
near black reached 4.5:1 on the year's band. The band is taken towards the dark
until the year reads, as the other covers do for small words.
2026-10-24 20:13:42 +02:00
Maurice b3d30a834e test(studio): pin which texts count as a template's figures 2026-10-24 20:13:42 +02:00
Maurice 589d3354eb fix(studio): a template's figures stay its design, never the next template's date line
Figures a template draws as design, a film's timecode or a frame number, came
back as the page's words when another template was laid on the page, and took
its date line. Texts with no letter that are not a date and are bound to
nothing are design now, and the new templates spell their plate numbers and
REC marks so they read as such.
2026-10-24 20:13:41 +02:00
Maurice 8ce3b24a01 feat(studio): 71 more designed pages and five new styles, each with a theme of its own
Every style now has four covers and five double spreads, 108 in all, and the
spreads of each style take different photo counts, from a words-only page to
eight photos, so a book can be laid on them without an empty frame. The five
new styles come with their own themes: Riso (overprinted spot inks), Magazine
(a travel magazine), Retro travel (vintage posters), Midnight (a dark film
look) and Field notes (a traveller's notebook). Each new style has a cover
composer for the design engine and a recipe for the assistant, and its chip,
theme and template names are translated into every language.
2026-10-24 20:13:41 +02:00
1981 changed files with 107273 additions and 8098 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules
client/node_modules
server/node_modules
shared/node_modules
client/dist
shared/dist
data
+64 -1
View File
@@ -5,6 +5,13 @@ name: Build & Push Docker Image (Prerelease)
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
#
# A prerelease asks the TREK services issuer for a registration dialect of its
# own -pre.N version (registration-dialect.yml, as the stable release does)
# and builds it into the image; nothing of it is committed, dev keeps the
# module of the last stable release, which it gets when main is merged back
# into dev. Without one the image carries dev's module, with a warning when
# that is the original one or old.
on:
workflow_dispatch:
@@ -89,10 +96,23 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
# The registration dialect of this prerelease, fetched and checked in a job
# without write access. It never fails the build.
dialect:
needs: version-bump
uses: ./.github/workflows/registration-dialect.yml
with:
version: ${{ needs.version-bump.outputs.version }}
ref: ${{ needs.version-bump.outputs.sha }}
secrets:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
needs: [version-bump, dialect]
# Whatever became of the dialect job: without its artifact the image carries dev's module.
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
strategy:
fail-fast: false
matrix:
@@ -111,6 +131,48 @@ jobs:
with:
ref: ${{ needs.version-bump.outputs.sha }}
- name: Take over the prerelease's dialect
id: take
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: registration-dialect
path: ${{ runner.temp }}/dialect
# Into the build context only; the commit stays as it is. Without a
# dialect of its own the image speaks dev's module, which the issuer
# takes for new installations for 180 days after it was made (longer
# while it is the newest stable release's). That is warned about, never
# stopped on, and a date that cannot be read only warns as well.
- name: Place the dialect
env:
VERSION: ${{ needs.version-bump.outputs.version }}
TAKEN: ${{ steps.take.outcome }}
REPORT: ${{ matrix.platform == 'linux/amd64' }}
run: |
set -u
target=server/src/nest/service-identity/registration-dialect.ts
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
cp "$fetched" "$target"
echo "$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target") of its own."
exit 0
fi
[ "$REPORT" = true ] || exit 0
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
if [ "$id" = 0 ]; then
message="$VERSION got no dialect of its own and registers in the original one, which the issuer accepts only for a transition period."
elif made="$(date -u -d "$created" +%s 2>/dev/null)"; then
age=$(( ($(date -u +%s) - made) / 86400 ))
message="$VERSION got no dialect of its own and registers in dev's dialect $id from $created, $age days old."
[ "$age" -gt 150 ] || { echo "$message"; exit 0; }
else
message="$VERSION got no dialect of its own, and the date of dev's dialect cannot be read from $target."
fi
echo "::warning::$message"
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -156,6 +218,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
if: ${{ !cancelled() && needs.build.result == 'success' }}
steps:
- uses: actions/checkout@v6
with:
+180 -21
View File
@@ -2,17 +2,29 @@ name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# 2. works out the version from the tags and the bump input,
# 3. asks the TREK services issuer for the registration dialect of that
# version and checks it, in a job without write access
# (registration-dialect.yml); without one the release ships the dialect
# already on main, but only while that one still has 90 days to run,
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
# dialect, tags it and pushes,
# 5. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. publishes the Helm chart from the same commit.
# 6. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
#
# Each stable release registers new installations in a dialect of its own,
# which the issuer accepts for 180 days, and for as long as it is the newest
# release's; renewing a key needs none. dev gets the module when main is merged
# back into dev after the release; a prerelease asks the issuer for one of its
# own (docker-dev.yml). The issuer's side, and what to set up before the first
# release with a dialect, is in the operations guide of the TREK services.
on:
workflow_dispatch:
@@ -26,6 +38,14 @@ 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
default: false
permissions:
contents: write
@@ -36,13 +56,16 @@ concurrency:
cancel-in-progress: false
jobs:
version-bump:
version:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
timeout-minutes: 30
permissions:
contents: read
checks: read
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
base: ${{ steps.base.outputs.SHA }}
steps:
- name: Release from main only
if: github.ref != 'refs/heads/main'
@@ -50,17 +73,17 @@ jobs:
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
# The bump job checks out exactly this commit, so what ci-ok was checked
# on is what gets released, however long the dialect takes.
- name: Note the commit being released
id: base
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
@@ -68,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: |
@@ -77,14 +101,43 @@ 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"
- name: Determine bump type and update version
# 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:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -143,6 +196,107 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# The registration dialect of this version, fetched and checked in a job
# without write access. It never fails; without an artifact from it the
# bump decides below whether the dialect on main may ship.
dialect:
needs: version
uses: ./.github/workflows/registration-dialect.yml
with:
version: ${{ needs.version.outputs.version }}
ref: ${{ needs.version.outputs.base }}
secrets:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
# Runs whatever became of the dialect job, a timeout or a lost runner
# included: no artifact means the dialect on main, if it may still ship.
version-bump:
needs: [version, dialect]
if: ${{ !cancelled() && needs.version.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
sha: ${{ steps.commit.outputs.SHA }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
ref: ${{ needs.version.outputs.base }}
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
- name: Take over the checked dialect
id: take
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: registration-dialect
path: ${{ runner.temp }}/dialect
# Without a dialect of its own the release ships the one on main, but
# only while the issuer takes new registrations in it for 90 more days:
# dialect 0 closes on the issuer's transition date, and a dialect with
# little left would shut out new installations of this release soon after
# it is out. Then this stops before anything is committed, unless
# allow_stale_dialect says to ship anyway. The issuer keeps the newest
# release's dialect open past its date, so the date here is the least it
# has, not the most.
- name: Place the dialect
env:
VERSION: ${{ needs.version.outputs.version }}
TAKEN: ${{ steps.take.outcome }}
ALLOW_STALE: ${{ inputs.allow_stale_dialect }}
run: |
set -u
target=server/src/nest/service-identity/registration-dialect.ts
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; }
report() {
echo "$2$1"
printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"
}
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
cp "$fetched" "$target"
report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." ""
exit 0
fi
id="$(dialect_id "$target")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
left=""
if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then
left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 ))
fi
if [ -n "$left" ] && [ "$left" -ge 90 ]; then
report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::"
exit 0
fi
if [ "$id" = 0 ]; then
what="the original registration, which the issuer accepts only until the end of its transition period"
elif [ -n "$left" ]; then
what="dialect $id from $created, which has $left days left"
else
what="a dialect whose id or date cannot be read from $target"
fi
if [ "$ALLOW_STALE" = true ]; then
report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::"
exit 0
fi
report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::"
exit 1
- name: Update the version, commit, tag and push
id: commit
env:
NEW_VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
@@ -153,7 +307,8 @@ jobs:
# landed on main in the meantime.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin HEAD:main --follow-tags
@@ -164,7 +319,9 @@ jobs:
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
needs: [version, version-bump]
# version-bump may run after a failed dialect job; that alone must not skip what follows it.
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
strategy:
fail-fast: false
matrix:
@@ -205,7 +362,7 @@ jobs:
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
APP_VERSION=${{ needs.version.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
@@ -227,7 +384,8 @@ jobs:
merge:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
needs: [version, version-bump, build]
if: ${{ !cancelled() && needs.build.result == 'success' }}
steps:
- uses: actions/checkout@v6
with:
@@ -252,7 +410,7 @@ jobs:
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
VERSION="${{ needs.version.outputs.version }}"
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
@@ -292,6 +450,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: version-bump
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v6
+144
View File
@@ -0,0 +1,144 @@
name: Registration dialect
# Asks the TREK services issuer for the registration dialect of one version and
# checks it, for docker.yml (a stable release commits it with the version bump)
# and docker-dev.yml (a prerelease only builds it into its image).
#
# The issuer's answer runs here, in a job that holds no write token and keeps
# no git credentials; the token for the issuer is in the environment of the
# one step that fetches. The answer is held to what a dialect is
# (scripts/ci/check-registration-dialect.mjs), then type-checked and linted
# the way TREK's CI would see it once it is on main and dev. Only a module that
# passes all of it goes on, as the artifact `registration-dialect`; without
# one the caller decides what ships. This workflow never fails its caller.
on:
workflow_call:
inputs:
version:
description: 'The version the dialect is for, X.Y.Z or X.Y.Z-pre.N'
type: string
required: true
ref:
description: 'The commit whose checker, types and lint config the dialect is checked with'
type: string
required: true
secrets:
TREK_DIALECT_TOKEN:
required: false
permissions:
contents: read
jobs:
dialect:
runs-on: ubuntu-24.04
timeout-minutes: 20
# A failed or timed-out run leaves no artifact, which the caller already
# handles; it must not fail the release around it.
continue-on-error: true
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
# The only step that sees the token. The URL is fixed; the answer is
# bounded in time and size and goes to a file nothing runs yet.
- name: Fetch the dialect from the issuer
id: fetch
timeout-minutes: 3
env:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
out="$RUNNER_TEMP/registration-dialect.ts"
if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then
echo "::warning::TREK_DIALECT_TOKEN is not set; the issuer was not asked for a dialect for $VERSION."
exit 0
fi
status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \
--max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \
--data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \
|| status=000
if [ "$status" != 200 ] && [ "$status" != 201 ]; then
echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status)."
rm -f "$out"
exit 0
fi
if [ "$(wc -c < "$out")" -gt 262144 ]; then
echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect."
rm -f "$out"
exit 0
fi
echo "file=$out" >> "$GITHUB_OUTPUT"
# A module that computes for long is refused by the clock as well.
- name: Check the dialect
id: check
if: steps.fetch.outputs.file != ''
timeout-minutes: 2
env:
FILE: ${{ steps.fetch.outputs.file }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then
echo "::warning::The issuer's registration dialect for $VERSION failed its check."
exit 0
fi
echo "What changes against the dialect in the tree:"
git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true
echo "ok=true" >> "$GITHUB_OUTPUT"
- name: Install the toolchain
if: steps.check.outputs.ok == 'true'
timeout-minutes: 8
run: |
npm ci --ignore-scripts --no-audit --no-fund
npm run build --workspace=shared
# The bump commit skips CI, so this is where the module meets the strict
# config (lint:strict lets a new file have no strict errors) and ESLint
# with --max-warnings 0 (lint:warnings starts a new file at none) before
# it lands on main.
- name: Type-check and lint the dialect
id: types
if: steps.check.outputs.ok == 'true'
timeout-minutes: 5
working-directory: server
env:
FILE: ${{ steps.fetch.outputs.file }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
cp "$FILE" src/nest/service-identity/registration-dialect.ts
# 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."
exit 0
fi
if ! npx eslint --max-warnings 0 src/nest/service-identity/registration-dialect.ts; then
echo "::warning::The issuer's registration dialect for $VERSION does not pass ESLint."
exit 0
fi
echo "ok=true" >> "$GITHUB_OUTPUT"
- name: Hand the dialect on
if: steps.types.outputs.ok == 'true'
uses: actions/upload-artifact@v6
with:
name: registration-dialect
path: ${{ steps.fetch.outputs.file }}
if-no-files-found: error
retention-days: 1
+33 -2
View File
@@ -1161,7 +1161,7 @@ jobs:
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
--security-opt no-new-privileges:true \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
-v "$RUNNER_TEMP/trek/data:/app/data" \
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
@@ -1194,6 +1194,17 @@ jobs:
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|| { echo "::error::/ does not serve the client"; exit 1; }
# Same capabilities as the shipped compose file: dumb-init has to be able
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
- name: Stop gracefully
run: |
set -euo pipefail
docker stop -t 30 trek-ci >/dev/null
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
docker logs trek-ci 2>&1 | grep 'SIGTERM received' >/dev/null \
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
- name: Container log
if: always()
run: docker logs trek-ci 2>&1 || true
@@ -1252,11 +1263,31 @@ 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 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 -2
View File
@@ -22,7 +22,6 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
@@ -92,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
+151 -93
View File
@@ -6,14 +6,9 @@
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
### Every part of your trip. One app you own.
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
TREK has grown from a travel planner into a whole ecosystem for trips: plan together in real time on a live map, build hikes, tours and road trips, keep bookings and shared costs in order, pack, write the journal, lay out the photo book and hand the busy work to the built-in AI. Self-hosted, on the web, on your phone and offline.
<br />
@@ -32,11 +27,16 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-AGPL_v3-6B7280?style=for-the-badge" /></a>
&nbsp;
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=for-the-badge&label=Release&color=6B7280" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=for-the-badge&label=Docker%20pulls&color=6B7280" /></a>
&nbsp;
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
</div>
---
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🥾 Tours
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
- **Multi-day tours**: one route cut into stages and spread across the trip's days
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧳 Bookings and money
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of nights with a check-in window
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
</td>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
#### 📔 Journal, Atlas and Vacay
</td>
</tr>
<tr>
<td width="50%" valign="top">
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
#### 📔 Journey and TREK Studio
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
</td>
<td width="50%" valign="top">
#### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🤖 AI assistant and MCP
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
</td>
</tr>
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 📱 Mobile and offline
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **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
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **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
- **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>
</tr>
@@ -184,7 +214,39 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
## Powered by dataforest cloud
<div align="center">
<br />
<a href="https://cloud.dataforest.net/?mtm_campaign=trek">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
<img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="60" />
</picture>
</a>
<br />
<br />
**Every TREK installation in the world searches through servers that dataforest cloud 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.
Thank you, dataforest, for backing open source this generously. 💚
<br />
<a href="https://cloud.dataforest.net/?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
</div>
<br />
@@ -204,12 +266,18 @@ the details.
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-e COOKIE_SECURE=false \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
> [!TIP]
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
<div align="center">
@@ -365,16 +433,6 @@ Caddy handles TLS and WebSockets automatically.
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
## Star History
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
</picture>
</a>
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
+5 -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,4 @@ 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.
- 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: 4.3.4
version: 5.1.0
description: Minimal Helm chart for TREK app
appVersion: "4.3.4"
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 }}
+15
View File
@@ -112,6 +112,21 @@ 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 }}
{{- if .Values.env.TREK_SERVICES_HOSTS }}
TREK_SERVICES_HOSTS: {{ .Values.env.TREK_SERVICES_HOSTS | quote }}
{{- end }}
{{- if .Values.env.TREK_PHOTON_URL }}
TREK_PHOTON_URL: {{ .Values.env.TREK_PHOTON_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_VALHALLA_URL }}
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
+12
View File
@@ -85,6 +85,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: PLACES_API_KEY
optional: true
- name: TREK_SERVICES_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: TREK_SERVICES_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
@@ -135,6 +141,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 }}
@@ -162,3 +171,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 }}
+6
View File
@@ -23,6 +23,9 @@ data:
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
@@ -77,6 +80,9 @@ stringData:
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
{{- end }}
+56 -3
View File
@@ -36,7 +36,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.
@@ -92,7 +92,8 @@ env:
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
# way. Defaults to https://nominatim.openstreetmap.org.
# way, and search and reverse lookups no longer go to the TREK services' Photon. Defaults to
# https://nominatim.openstreetmap.org.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
@@ -112,6 +113,24 @@ 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
# secretEnv.TREK_SERVICES_KEY. Defaults to auto.
# TREK_SERVICES_HOSTS: ""
# Comma-separated hosts that receive the installation key over https. Defaults to
# places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com.
# TREK_PHOTON_URL: ""
# The address search (Photon) of the TREK services. With a key, search and reverse lookups ask it before
# Nominatim and fall back to Nominatim when it does not answer. Never asked when NOMINATIM_URL is set.
# Defaults to https://photon.liketrek.com.
# TREK_VALHALLA_URL: ""
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
@@ -164,6 +183,9 @@ secretEnv:
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# Optional key issued for this installation by the TREK services. Wins over the key TREK
# registers by itself and turns that registration off. Leave empty to let TREK register.
TREK_SERVICES_KEY: ""
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
# push off with an error in the log until it is fixed, and every device stays registered.
@@ -179,7 +201,7 @@ generateEncryptionKey: false
# If set, the chart renders no Secret of its own and instead reads every secret env
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET, TREK_SERVICES_KEY,
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
# Missing keys are simply left unset (they are optional), so only provide the ones your
# setup needs.
@@ -242,6 +264,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
@@ -262,3 +298,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"
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.4",
"version": "5.1.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -41,9 +41,7 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
@@ -94,7 +92,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"maplibre-gl": "6.15.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.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

+12 -12
View File
@@ -1,16 +1,16 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-non-null-assertion": 380,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 221,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/refs": 555,
"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,
@@ -20,7 +20,7 @@
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 44,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -30,16 +30,16 @@
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 40,
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1929,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
"trek/no-window-globals": 331,
"trek/no-window-globals": 325,
"trek/no-swallowed-catch": 14,
"react-hooks/globals": 5,
"react-hooks/refs": 22,
+26 -2
View File
@@ -65,7 +65,7 @@
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
@@ -130,6 +130,15 @@
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Booking save follow-up: travellers and files before the editor closes",
"hooks": ["src/components/Planner/useBookingFollowUp.ts"],
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
"mobile": [
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Bookings and transports lists",
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
@@ -661,9 +670,24 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Settings / Demo account locks",
"hooks": ["src/components/Demo/useDemoLock.ts"],
"desktop": [
"src/components/Settings/AccountDemoLock.tsx",
"src/components/Settings/DisplaySettingsTab.tsx",
"src/components/Settings/IntegrationsTab.tsx",
"src/components/Settings/ai/AiConnectionSection.tsx"
],
"mobile": [
"src/mobile/screens/settings/MAiConnectionSection.tsx",
"src/mobile/screens/settings/MSettingsGeneral.tsx",
"src/mobile/screens/settings/MSettingsMcp.tsx"
]
},
{
"feature": "Settings / Account",
"hooks": ["src/components/Settings/useAccountSettings.ts"],
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
"desktop": ["src/components/Settings/AccountTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
-6
View File
@@ -55,7 +55,6 @@
"scripts/check-gl-split.mjs",
"scripts/check-page-pattern.mjs",
"scripts/generate-icons.mjs",
"scripts/patch-maplibre.mjs",
"src/App.test.tsx",
"src/App.tsx",
"src/App.versionHandover.test.tsx",
@@ -409,7 +408,6 @@
"src/components/Journey/usePhotoZoom.ts",
"src/components/Layout/BottomNav.test.tsx",
"src/components/Layout/BottomNav.tsx",
"src/components/Layout/DemoBanner.tsx",
"src/components/Layout/InAppNotificationBell.test.tsx",
"src/components/Layout/InAppNotificationBell.tsx",
"src/components/Layout/MobileTopBar.test.tsx",
@@ -901,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",
@@ -993,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
@@ -1,5 +1,5 @@
{
"admin.ts": 126,
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
-1
View File
@@ -150,7 +150,6 @@
"src/pages/resetPassword/useResetPassword.ts",
"src/pages/settings/useSettings.ts",
"src/pages/sharedTrip/useSharedTrip.ts",
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
-34
View File
@@ -1,34 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
const require = createRequire(import.meta.url)
const packagePath = require.resolve('maplibre-gl/package.json')
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
const loops = [
['of elem.attributes)', 'of Array.from(elem.attributes))'],
['of e.attributes)', 'of Array.from(e.attributes))'],
]
for (const filename of [
'src/util/dom.ts',
'dist/maplibre-gl.js',
'dist/maplibre-gl-dev.js',
'dist/maplibre-gl-csp.js',
'dist/maplibre-gl-csp-dev.js',
]) {
const target = join(dirname(packagePath), filename)
const code = readFileSync(target, 'utf8')
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
const [before, after] = loop
if (code.split(before).length === 1 && code.split(after).length === 2) continue
if (code.split(before).length !== 2 || code.includes(after)) {
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
}
writeFileSync(target, code.replace(before, after))
}
+1 -3
View File
@@ -1,5 +1,5 @@
{
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Budget/BudgetPanelMemberChips.tsx": 6,
"components/Budget/CostsPanel.tsx": 3,
"components/Budget/ReceiptPreviewModal.tsx": 2,
@@ -59,8 +59,6 @@
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
"components/SystemNotices/releaseNotice.css": 2,
"components/Vacay/VacayCalendar.tsx": 3,
"components/Vacay/VacayMonthCard.tsx": 7,
"components/Vacay/VacaySettings.tsx": 1,
+1
View File
@@ -1,4 +1,5 @@
{
"components/Collections/CollectionMap.tsx": 1,
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
+6 -8
View File
@@ -2,19 +2,17 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/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,
@@ -23,7 +21,7 @@
"src/pages/JourneyDetailPage.test.tsx": 4030,
"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 -4
View File
@@ -1,7 +1,7 @@
{
"App.tsx": 11,
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/DefaultUserSettingsTab.tsx": 1,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Atlas/AtlasCountryPlaces.tsx": 6,
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
"components/Budget/BudgetPanel.helpers.ts": 2,
@@ -51,7 +51,6 @@
"components/Journey/journeyMapPopup.ts": 17,
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
@@ -106,7 +105,6 @@
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
"components/Todo/TodoListPanel.tsx": 6,
@@ -207,7 +205,7 @@
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 21,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
+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,
+2
View File
@@ -229,6 +229,8 @@ export const aiAdminApi = {
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
+1
View File
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
+2 -2
View File
@@ -574,9 +574,9 @@ export const adminApi = {
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
+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: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+8
View File
@@ -0,0 +1,8 @@
import apiClient from './client';
/** /api/admin/service-identity: how this installation stands with the TREK services. Admin only. */
export const serviceIdentityApi = {
status: (signal?: AbortSignal): Promise<unknown> =>
apiClient.get('/admin/service-identity', { signal }).then((r) => r.data),
regenerate: (): Promise<unknown> => apiClient.post('/admin/service-identity/regenerate').then((r) => r.data),
};
+19
View File
@@ -0,0 +1,19 @@
import type { ValhallaRelayAction } from '@trek/shared';
/**
* POST /api/routing/valhalla/:action: a question for the TREK services'
* Valhalla, which this server asks with the installation key. The answer is
* Valhalla's own, its status included, so it is handed back as a Response and
* read like the answer of any other Valhalla. Plain fetch on purpose: the
* shared client would turn a 401 into a sign-in redirect and a 429 into a
* toast, where the planner only wants to ask FOSSGIS instead.
*/
export function postValhallaRelay(action: ValhallaRelayAction, body: unknown, signal?: AbortSignal): Promise<Response> {
return fetch(`/api/routing/valhalla/${action}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, I
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
+8 -2
View File
@@ -19,6 +19,7 @@ export default function AddonSubRow({
icon,
title,
description,
badge,
enabled,
onToggle,
}: {
@@ -26,6 +27,8 @@ export default function AddonSubRow({
icon?: ReactNode
title: string
description?: string
/** A small pill after the title, e.g. the beta mark on the assistant. */
badge?: ReactNode
enabled: boolean
onToggle: () => void
}) {
@@ -34,8 +37,11 @@ export default function AddonSubRow({
{icon
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
: <span className="w-6 flex-none" aria-hidden />}
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
{title}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
{title}
</span>
{badge}
</span>
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
</li>
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
fireEvent.click(await screen.findByText('Discover'))
}
it('blocks Install when no published version runs on this TREK, and says why', async () => {
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
expect(btn).toBeDisabled()
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
})
it('offers the newest version that DOES run here rather than a dead button', async () => {
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
}
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
async function pickFilter(trigger: RegExp, option: RegExp) {
fireEvent.click(await screen.findByTitle(trigger))
fireEvent.click(await screen.findByRole('button', { name: option }))
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
async function pickFilter(option: RegExp) {
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
}
/** The enable toggle of a named installed row. */
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Type: All types$/, /^Widget$/)
await pickFilter(/^Widget$/)
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('Countdown')).toBeInTheDocument()
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
})
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('OnOne')
await pickFilter(/^Status: All$/, /^Off$/)
await pickFilter(/^Off$/)
expect(await screen.findByText('OffOne')).toBeInTheDocument()
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
await pickFilter(/^Status: Off$/, /^Error$/)
await pickFilter(/^Error$/)
expect(await screen.findByText('BadOne')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
await pickFilter(/^Status: Error$/, /^Update available$/)
await pickFilter(/^Update available$/)
expect(await screen.findByText('OnOne')).toBeInTheDocument()
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
})
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Sort: Name$/, /^Updates first$/)
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
await pickFilter(/^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await clickDiscover()
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
})
})
describe('AdminPluginsPanel — security footer', () => {
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
describe('AdminPluginsPanel — security dialog', () => {
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
panelWith([])
render(<AdminPluginsPanel />)
await screen.findByText('No plugins installed yet.')
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
]
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
await user.click(screen.getByTitle(filter))
await user.click(screen.getByRole('button', { name: option }))
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: option }))
}
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Type:/, /^Widget$/)
await pickFilter(user, /^Widget$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Status:/, /^Active$/)
await pickFilter(user, /^Active$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Off$/)
await pickFilter(user, /^Off$/)
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Error$/)
await pickFilter(user, /^Error$/)
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
})
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
await pickFilter(user, /^Sort:/, /^Updates first$/)
await pickFilter(user, /^Updates first$/)
await waitFor(() => {
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
expect(names[0]).toBe('Gamma Page')
})
await pickFilter(user, /^Status:/, /^Update available$/)
await pickFilter(user, /^Update available$/)
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
})
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Sort:/, /^Updates first$/)
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
await pickFilter(user, /^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Old Tool')
await pickFilter(user, /^Sort:/, /^Most downloads$/)
await pickFilter(user, /^Most downloads$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Recently updated$/)
await pickFilter(user, /^Recently updated$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Name$/)
await pickFilter(user, /^Name$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Mapper')
await pickFilter(user, /^Type:/, /^Page$/)
await pickFilter(user, /^Page$/)
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
expect(screen.getByText('Poster')).toBeInTheDocument()
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTitle(/^Type:/))
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
await user.click(filterButton())
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
fireEvent.click(layer)
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
})
})
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
await screen.findByText('Alpha Widget')
await openDiscover(user)
await user.click(screen.getByTitle(/^Sort:/))
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
expect(filterButton()).toHaveTextContent('Most downloads')
await user.click(screen.getByRole('tab', { name: /Installed/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
expect(offer).not.toBeDisabled()
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
expect(dead).toBeDisabled()
expect(dead.getAttribute('title')).toBeTruthy()
// Nothing fits: no dead button any more, but a card that says so before anything installs.
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
})
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
const btn = await screen.findByRole('button', { name: /^install$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
+33 -384
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useState, type ReactNode } from 'react'
import {
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, CircleDot, Database,
Globe, History, Puzzle, Bot, Loader2,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { useInstanceSettings } from './useInstanceSettings'
@@ -23,15 +22,17 @@ import {
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
} from '../Settings/settingsKit'
import { POPOVER } from '../Packing/packingPopoverStyles'
import {
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
installOffer, signatureBlockView,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
type T, type VersionInfo, type VersionMismatch,
} from './plugins/pluginModel'
import { usePluginDetail } from './plugins/usePluginDetail'
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
* tile; the security section at the bottom explains the model honestly.
*/
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
}
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
/** The eyebrow over a block, the size the dialogs' sections use. */
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
/** A capability or dependency chip on an installed row. */
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
const CHIP_NET = 'border-transparent bg-info-soft text-info'
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
/** A small action inside a row or a card: the kit's button, a size down. */
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
/** A line of state under a row's name: a refused update, a paused one, an error. */
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
/** A pill that explains itself on hover. */
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
return (
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
)
}
function ReviewedBadge({ t }: { t: T }) {
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
}
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
function SideloadedBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.sideloadedHint')}>
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
</HintPill>
)
}
/**
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
*
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
)
}
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
function DevLinkBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.devLinkHint')}>
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
</HintPill>
)
}
function TypeBadge({ type, t }: { type: string; t: T }) {
return (
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
uploadPlugin, versionPicker, view,
} = usePluginsAdmin()
const [securityOpen, setSecurityOpen] = useState(false)
return (
<div className="relative mb-24 sm:mb-0"
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
) : undefined}
action={ready ? (
<>
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
<ShieldCheck size={15} strokeWidth={2} />
</BandButton>
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
<UploadCloud size={15} strokeWidth={2} />
</BandButton>
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
/>
</label>
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
options={[
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
]}
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
onPick={v => setTypeFilter(v as TypeFilter)} />
{view === 'installed' && (
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
options={[
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
]}
valueLabel={statusLabel(statusFilter, t)}
onPick={v => setStatusFilter(v as StatusFilter)} />
)}
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
options={view === 'discover'
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
valueLabel={sortLabel(sort, t)}
onPick={v => setSort(v as SortKey)} />
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
menu={menu} setMenu={setMenu} />
</div>
{view === 'discover' ? (
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
) : (
<SettingRows>
{shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
)}
</SettingsCard>
<SecurityInfo t={t} />
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
{/* Registry detail dialog */}
{detailFor && (
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
)
}
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0)
useEffect(() => {
if (!open) return
const onMove = () => reanchor(n => n + 1)
window.addEventListener('scroll', onMove, true)
window.addEventListener('resize', onMove)
return () => {
window.removeEventListener('scroll', onMove, true)
window.removeEventListener('resize', onMove)
}
}, [open])
}
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
id: string; label: string; valueLabel: string
options: Array<[string, string]>; onPick: (v: string) => void; value: string
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
}) {
const open = menu === id
const active = value !== options[0]?.[0]
const btnRef = useRef<HTMLButtonElement>(null)
// Portaled like the row menu: the card around the toolbar clips what overflows it.
useFollowTrigger(open)
return (
<div className="relative flex-none">
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
style={fs(12.5, 'body')}>
<span className="flex-none text-content-faint">{icon}</span>
<span>{label}: </span>
<span className="font-semibold text-content">{valueLabel}</span>
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
{options.map(([v, lbl]) => (
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
</button>
))}
</div>,
document.body,
)}
</div>
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
const menuOpen = menu === `row:${p.id}`
const menuBtnRef = useRef<HTMLButtonElement>(null)
const working = busy === p.id
const on = p.enabled === 1 && p.status !== 'error'
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
// Re-anchor while the page moves under it.
useFollowTrigger(menuOpen)
const hostCount = p.egressHostCount ?? 0
return (
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
</div>
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
its own row state, not an error state, and it persists until it is resolved
rather than dying with the toast that first reported it. */}
{blocked && p.updateBlock && (
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
<ShieldAlert size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
<button type="button" onClick={onReviewBlock}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
{t('admin.plugins.reviewBlock')}
</button>
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<Info size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<PauseCircle size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={working}
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
</div>
) : (caps.length > 0 || p.operatorEgress) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
</span>
))}
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={onEgress}
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
<Globe size={12} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
)}
{deps.length > 0 && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{deps.map((d, i) => (
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
</span>
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button type="button" onClick={onUpdate} disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen && createPortal(
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]">
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
never be invited to widen egress for a plugin that didn't ask for it
(same rule the row's egress chip follows). */}
{p.operatorEgress && (
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
</a>
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
</div>,
document.body,
)}
</div>
</div>
</div>
)
}
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
const ROW_MENU_MAX_H = 260
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect()
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
}
}
/** One entry of a popover menu, in the packing popovers' shape. */
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button type="button" onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
</button>
)
}
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
const [failed, setFailed] = useState(false)
return (
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
</span>
)}
{/* Says up front that this one has not been confirmed for the TREK it would run on,
so "Install" never reads as a plain install; the dialog explains the risk. */}
{!installed && offer.warn && (
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
style={fs(11)}>
<AlertTriangle size={11} className="flex-none" />
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
</span>
)}
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
</span>
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
}) {
return (
<DialogShell
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
{confirm ? (
<>
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
</>
) : (
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
</DialogShell>
)
}
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
// panel that lays out how plugins are contained, the limits, and the worst case.
function SecurityInfo({ t }: { t: T }) {
const [open, setOpen] = useState(false)
const sections: Array<[string, string]> = [
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
]
return (
<SettingsCard
icon={Lock}
title={(
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
<span className="truncate">{t('admin.plugins.security.title')}</span>
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
>
<div className="flex items-start gap-2.5">
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
</div>
{open && (
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
</div>
))}
</div>
)}
</SettingsCard>
)
}
@@ -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,204 @@
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
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();
}
});
});
@@ -0,0 +1,200 @@
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.
*/
export default function PlaceContribPanel() {
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;
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')}
</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,4 +1,5 @@
import { useId, useState } from 'react'
import type { ValhallaProvider } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
import { fs } from '../shared/DialogShell'
import { INPUT, LABEL } from '../shared/dialogParts'
@@ -6,6 +7,7 @@ import { INPUT, LABEL } from '../shared/dialogParts'
export interface RoutingDefaults {
routing_base_url?: string
valhalla_base_url?: string
routing_valhalla_provider?: ValhallaProvider
}
interface RoutingFieldsProps {
@@ -25,6 +27,14 @@ const ROUTERS = [
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
] as const
// '' is no choice of the admin: the instance then decides as before the choice existed.
const PROVIDERS = [
{ value: '', labelKey: 'settings.valhallaProviderOption.auto' },
{ value: 'trek', labelKey: 'settings.valhallaProviderOption.trek' },
{ value: 'fossgis', labelKey: 'settings.valhallaProviderOption.fossgis' },
{ value: 'custom', labelKey: 'settings.valhallaProviderOption.custom' },
] as const
// The phone screen's look, unchanged: MAdminDefaultUserSettings passes its own hint class.
const PHONE = {
wrap: 'min-w-0 space-y-1.5',
@@ -43,6 +53,12 @@ const DESKTOP = {
input: INPUT,
}
function FieldHint({ id, text, hintClassName }: { id: string; text: string; hintClassName?: string }) {
return hintClassName !== undefined
? <p id={id} className={hintClassName}>{text}</p>
: <p id={id} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{text}</p>
}
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
const { t } = useTranslation()
const id = useId()
@@ -81,14 +97,45 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: R
aria-describedby={`${id}-hint`}
className={look.input}
/>
{phone
? <p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
: <p id={`${id}-hint`} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{t(router.hint)}</p>}
<FieldHint id={`${id}-hint`} text={t(router.hint)} hintClassName={hintClassName} />
</div>
)
}
/** Which Valhalla answers what OSRM cannot; picking "automatic" clears the choice. */
function RoutingProviderField({ defaults, onSave, onReset, hintClassName }: RoutingFieldsProps) {
const { t } = useTranslation()
const id = useId()
const [saving, setSaving] = useState(false)
const phone = hintClassName !== undefined
const look = phone ? PHONE : DESKTOP
const pick = async (value: string) => {
setSaving(true)
try {
if (value === '') await onReset('routing_valhalla_provider')
else await onSave({ routing_valhalla_provider: value as ValhallaProvider })
} finally { setSaving(false) }
}
return (
<div className={`${look.wrap} ${!phone && saving ? 'opacity-60' : ''}`} aria-busy={saving}>
<div className={look.head}>
<label htmlFor={id} className={look.label}>{t('settings.valhallaProvider')}</label>
</div>
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
<FieldHint id={`${id}-hint`} text={t('settings.valhallaProviderHint')} hintClassName={hintClassName} />
</div>
)
}
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
const phone = props.hintClassName !== undefined
return <div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
return (
<div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>
<RoutingProviderField {...props} />
{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}
</div>
)
}
@@ -0,0 +1,160 @@
// FE-ADMIN-SVCID-001 to FE-ADMIN-SVCID-010
import userEvent from '@testing-library/user-event';
import type { ServiceIdentityStatus } 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 ServiceIdentityPanel from './ServiceIdentityPanel';
// 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 QUOTA = { limit: 10_000, used: 2_500, remaining: 7_500, resetAt: '2026-10-30T00:00:00.000Z' };
const CONNECTED: ServiceIdentityStatus = {
state: 'connected',
tier: 'std',
keySource: 'registered',
services: [
{ service: 'places', inUse: true, quota: QUOTA },
{ service: 'photon', inUse: false, quota: null },
{ service: 'valhalla', inUse: false, quota: null },
],
lastSeenAt: '2026-10-29T12:00:00.000Z',
};
function serve(status: ServiceIdentityStatus) {
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json(status)));
}
describe('ServiceIdentityPanel', () => {
it('FE-ADMIN-SVCID-001: shows the state, the tier and today against the limit', async () => {
serve(CONNECTED);
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.state.connected')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.tier.std')).toBeInTheDocument();
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
expect(bar).toHaveAttribute('aria-valuenow', '2500');
expect(bar).toHaveAttribute('aria-valuemax', '10000');
// What is left is the figure of the tile; used and the limit sit beside it as badges.
expect(screen.getByText('7,500')).toBeInTheDocument();
expect(screen.getByText('2,500')).toBeInTheDocument();
expect(screen.getByText('10,000')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
// The day starts again at the same time for every service, so it is said once, in the header.
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
});
it('FE-ADMIN-SVCID-002: an installation without a key says so and offers a new identity', async () => {
serve({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null });
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.hint.legacy')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).toBeNull();
expect(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeInTheDocument();
});
it('FE-ADMIN-SVCID-003: switched off, it shows why and offers nothing', async () => {
serve({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null });
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.hint.off')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
});
it('FE-ADMIN-SVCID-004: a key from the environment is named, and only changed there', async () => {
serve({ ...CONNECTED, keySource: 'env', tier: 'own' });
render(<ServiceIdentityPanel />);
// A badge naming the variable; the sentence explaining it is its tooltip.
expect(await screen.findByText('TREK_SERVICES_KEY')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.key')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
});
it('FE-ADMIN-SVCID-005: a service in use without an allowance to show says it cannot be read', async () => {
serve({ ...CONNECTED, services: [{ service: 'places', inUse: true, quota: null }] });
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).toBeNull();
});
it('FE-ADMIN-SVCID-009: lists place search, address search and routing, each with its own allowance', async () => {
serve({
...CONNECTED,
services: [
{ service: 'places', inUse: true, quota: QUOTA },
{ service: 'photon', inUse: true, quota: { ...QUOTA, used: 40, remaining: 9_960 } },
{ service: 'valhalla', inUse: true, quota: { ...QUOTA, used: 9_500, remaining: 500 } },
],
});
render(<ServiceIdentityPanel />);
const list = await screen.findByRole('list', { name: 'admin.trekApi.quota.title' });
expect(list.querySelectorAll('li')).toHaveLength(3);
const value = (name: string) => screen.getByRole('progressbar', { name }).getAttribute('aria-valuenow');
expect(value('admin.trekApi.quota.service.places')).toBe('2500');
expect(value('admin.trekApi.quota.service.photon')).toBe('40');
expect(value('admin.trekApi.quota.service.valhalla')).toBe('9500');
expect(screen.queryByText('admin.trekApi.quota.notInUse')).toBeNull();
});
it('FE-ADMIN-SVCID-010: a service this instance does not ask is named as not in use, its allowance still shown', async () => {
serve({
...CONNECTED,
services: [
{ service: 'places', inUse: true, quota: QUOTA },
{ service: 'photon', inUse: false, quota: null },
{ service: 'valhalla', inUse: false, quota: QUOTA },
],
});
render(<ServiceIdentityPanel />);
expect(await screen.findAllByText('admin.trekApi.quota.notInUse')).toHaveLength(2);
expect(screen.getAllByRole('progressbar')).toHaveLength(2);
expect(screen.queryByText('admin.trekApi.quota.unavailable')).toBeNull();
});
it('FE-ADMIN-SVCID-006: a failed read says so instead of showing nothing', async () => {
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
});
it('FE-ADMIN-SVCID-007: a revoked key asks before it starts over, then shows the new state', async () => {
let regenerated = 0;
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
server.use(
http.post('/api/admin/service-identity/regenerate', () => {
regenerated += 1;
return HttpResponse.json({
state: 'registering',
tier: 'legacy',
keySource: null,
services: [],
lastSeenAt: null,
});
})
);
const user = userEvent.setup();
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' }));
expect(screen.getByText('admin.trekApi.quota.regenerateBody')).toBeInTheDocument();
expect(regenerated).toBe(0);
const regenerateButtons = screen.getAllByRole('button', { name: 'admin.trekApi.quota.regenerate' });
const confirm = regenerateButtons[regenerateButtons.length - 1];
if (!confirm) throw new Error('no confirm button');
await user.click(confirm);
await waitFor(() => expect(regenerated).toBe(1));
expect(await screen.findByText('admin.trekApi.quota.hint.registering')).toBeInTheDocument();
});
it('FE-ADMIN-SVCID-008: a managed install leaves the identity to its operator', async () => {
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
render(<ServiceIdentityPanel managed />);
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
});
});
@@ -0,0 +1,216 @@
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
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';
import { useServiceIdentity } from './useServiceIdentity';
const STATE_DOT: Record<ServiceIdentityState, string> = {
connected: 'bg-success',
registering: 'bg-warning motion-safe:animate-pulse',
legacy: 'bg-[var(--text-faint)]',
off: 'bg-[var(--text-faint)]',
revoked: 'bg-danger',
};
const SERVICE_ICON: Record<string, LucideIcon> = { places: MapPin, photon: Search, valhalla: Route };
/** Below a tenth of the day left the bar turns amber, at nothing left red. */
function barTone(quota: ServiceQuotaView): string {
if (quota.remaining === 0) return 'bg-danger';
return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent';
}
function figureTone(quota: ServiceQuotaView): string {
if (quota.remaining === 0) return 'text-danger';
return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content';
}
/** The clock time the counts start again, in the user's format; one for all services, which share the day. */
function useResetTime(status: ServiceIdentityStatus | null): string | null {
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
const at = status?.services.find((s) => s.quota)?.quota?.resetAt;
if (!at) return null;
const reset = new Date(at);
return formatClockTime(
`${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`,
is12h
);
}
function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
}
/**
* One service under the key, as a tile: what is left today as the figure that
* matters, the day's use as a bar, used and limit as badges. A service not in
* use is dimmed rather than hidden, so the row reads the same on every install.
*/
function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
const { t, locale } = useTranslation();
const number = new Intl.NumberFormat(locale);
const name = t(`admin.trekApi.quota.service.${allowance.service}`);
const note = tileNote(t, allowance);
const quota = allowance.quota;
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
return (
<li
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
<Icon size={14} strokeWidth={2} aria-hidden="true" />
</span>
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
</div>
{quota && (
<>
<div className="min-w-0">
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
{number.format(quota.remaining)}
</p>
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
</div>
<div
role="progressbar"
aria-label={name}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
style={{ width: `${share}%` }}
/>
</div>
<div className="flex flex-wrap gap-1.5">
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
</div>
</>
)}
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
</li>
);
}
function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null {
if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null;
return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`);
}
/**
* How this installation stands with the TREK services: whether it has its key,
* and for place search, address search and routing today's allowance against
* what is used, and when the count starts again.
* Read when the card is in view and refreshed every 30 seconds while it stays.
* A managed install leaves the identity to its operator, so it offers no new one.
*/
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
const { t } = useTranslation();
const toast = useToast();
const panel = useRef<HTMLDivElement>(null);
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
const [confirming, setConfirming] = useState(false);
const resetAt = useResetTime(status);
const confirmRegenerate = async () => {
setConfirming(false);
try {
await regenerate();
toast.success(t('admin.trekApi.quota.regenerated'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')));
}
};
const hint = stateHint(t, status, failed);
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
return (
<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']}`}
/>
{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>}
{/* 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}
onClose={() => setConfirming(false)}
onConfirm={() => void confirmRegenerate()}
title={t('admin.trekApi.quota.regenerateTitle')}
message={t('admin.trekApi.quota.regenerateBody')}
confirmLabel={t('admin.trekApi.quota.regenerate')}
danger={false}
/>
</div>
);
}
@@ -66,6 +66,7 @@ export default function AiAddonTile({
icon={<Icon size={14} />}
title={label.name}
description={label.description}
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
@@ -93,3 +94,16 @@ export default function AiAddonTile({
</article>
);
}
/** The assistant is still young; its switch says so with a small accent pill. */
function BetaBadge({ label }: { label: string }): React.ReactElement {
return (
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
style={fs(9)}
>
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
{label}
</span>
);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
import type { Addon } from './addonModel';
import { usesConnection } from './aiAddons';
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
*/
export function useAiAddonTile(aiAddons: Addon[]) {
const [params] = useSearchParams();
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
const canSetUp = usesConnection(aiAddons);
return {
enabledCount: aiAddons.filter((a) => a.enabled).length,
@@ -1,11 +1,13 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -14,6 +16,7 @@ const repo = vi.hoisted(() => ({
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
setAdminModel: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
@@ -59,6 +62,8 @@ async function renderLoaded() {
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
// The settings show while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
@@ -153,6 +158,29 @@ describe('useAiAdminConfig', () => {
});
});
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
const plan = (model: string): AiAdminConnectionResponse => ({
connection: {
scope: 'instance',
kind: 'chatgpt',
model,
hint: null,
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
});
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
expect(toast.success).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
/**
* Another model on the same connection: the stored key is kept by sending the mask in its place.
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
*/
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
if (!live) return;
if (live.kind === 'chatgpt') {
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
return;
}
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
@@ -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,167 @@
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import { fs } from '../../shared/DialogShell';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
const MENU_ID = 'filters';
interface Section {
title: string;
value: string;
options: Array<[string, string]>;
onPick: (v: string) => void;
}
/**
* Type, status and order of the plugin list behind one button instead of three, so the
* toolbar stays search plus one control. The button counts the filters that narrow the
* list (the order does not narrow it) and names the order it is in.
*/
export default function PluginFilterMenu({
t,
view,
typeFilter,
setTypeFilter,
statusFilter,
setStatusFilter,
sort,
setSort,
menu,
setMenu,
}: {
t: T;
view: 'installed' | 'discover';
typeFilter: TypeFilter;
setTypeFilter: (v: TypeFilter) => void;
statusFilter: StatusFilter;
setStatusFilter: (v: StatusFilter) => void;
sort: SortKey;
setSort: (v: SortKey) => void;
menu: string | null;
setMenu: (v: string | null) => void;
}) {
const open = menu === MENU_ID;
const btnRef = useRef<HTMLButtonElement>(null);
useFollowTrigger(open);
const sections: Section[] = [
{
title: t('admin.plugins.filterType'),
value: typeFilter,
options: [
['all', t('admin.plugins.allTypes')],
['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')],
['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
],
onPick: (v) => setTypeFilter(v as TypeFilter),
},
...(view === 'installed'
? [
{
title: t('admin.plugins.filterStatus'),
value: statusFilter,
options: [
['all', t('admin.plugins.allStatuses')],
['on', t('admin.plugins.status.active')],
['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')],
['err', t('admin.plugins.status.error')],
] as Array<[string, string]>,
onPick: (v: string) => setStatusFilter(v as StatusFilter),
},
]
: []),
{
title: t('admin.plugins.sortBy'),
value: sort,
options:
view === 'discover'
? [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['downloads', t('admin.plugins.sortDownloads')],
]
: [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['updates', t('admin.plugins.sortUpdates')],
],
onPick: (v) => setSort(v as SortKey),
},
];
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
return (
<div className="relative flex-none">
<button
type="button"
ref={btnRef}
onClick={() => setMenu(open ? null : MENU_ID)}
aria-expanded={open}
aria-haspopup="menu"
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
}`}
style={fs(12.5, 'body')}
>
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
{narrowing > 0 && (
<span
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
style={fs(10.5)}
>
{narrowing}
</span>
)}
<span className="text-content-faint">{sortLabel(sort, t)}</span>
<ChevronDown
size={13}
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open &&
createPortal(
<div
role="menu"
className="w-[240px] max-w-[calc(100vw-2rem)]"
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
>
{sections.map((s, i) => (
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
<p
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(10)}
>
{s.title}
</p>
{s.options.map(([v, label]) => (
<button
type="button"
role="menuitemradio"
aria-checked={s.value === v}
key={v}
onClick={() => s.onPick(v)}
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
<Check
size={14}
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
/>
</button>
))}
</div>
))}
</div>,
document.body
)}
</div>
);
}
@@ -0,0 +1,426 @@
import {
AlertTriangle,
ArrowUpCircle,
Bug,
CircleDot,
Github,
Globe,
History,
Info,
KeyRound,
Link2,
MoreHorizontal,
PauseCircle,
RotateCw,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Trash2,
UploadCloud,
} from 'lucide-react';
import { useRef, type ComponentType, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { fs } from '../../shared/DialogShell';
import PluginIcon from '../../shared/PluginIcon';
import { Tooltip } from '../../shared/Tooltip';
import { deriveCaps } from '../pluginCaps';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
};
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
const FACT =
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
type Tone = 'warning' | 'danger' | 'neutral';
const NOTICE_LOOK: Record<Tone, string> = {
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
neutral: 'bg-surface-secondary text-content-muted',
};
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
interface Notice {
key: string;
tone: Tone;
icon: ComponentType<{ size?: number; className?: string }>;
text: string;
action?: { label: string; onClick: () => void; disabled?: boolean };
}
function Fact({
icon,
iconClass = 'text-content-muted',
hint,
children,
}: {
icon: ReactNode;
iconClass?: string;
hint?: string;
children: ReactNode;
}) {
const chip = (
<span className={FACT} style={fs(11)}>
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
<span className="truncate">{children}</span>
</span>
);
return hint ? (
<Tooltip label={hint}>
<span className="inline-flex max-w-full">{chip}</span>
</Tooltip>
) : (
chip
);
}
function NoticeLine({ n }: { n: Notice }) {
return (
<div
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
style={fs(11.5)}
>
<n.icon size={13} className="flex-none" />
<span className="min-w-0 flex-1 truncate">{n.text}</span>
{n.action && (
<button
type="button"
onClick={n.action.onClick}
disabled={n.action.disabled}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
>
{n.action.label}
</button>
)}
</div>
);
}
function MenuItem({
icon,
label,
onClick,
danger,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
{icon}
</span>{' '}
{label}
</button>
);
}
interface InstalledRowProps {
p: PluginRow;
t: T;
busy: string | null;
menu: string | null;
setMenu: (v: string | null) => void;
hasUpdate: boolean;
latestVer?: string;
newerIncompatible: { version: string; range: string } | null;
blocked: boolean;
onToggle: () => void;
onUpdate: () => void;
onRestart: () => void;
onChangeVersion: () => void;
onResume: () => void;
onReviewBlock: () => void;
onErrors: () => void;
onEgress: () => void;
onSettings: () => void;
onUninstall: () => void;
}
/**
* One installed plugin, read top to bottom: its name and version, what it does, the
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
* signed, where it may connect), then each state that needs attention on a line of its
* own with the action that resolves it. The controls stay on the right.
*/
export default function PluginInstalledRow(props: InstalledRowProps) {
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
const caps = deriveCaps(
parseJson<string[]>(p.permissions, []),
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
t
);
const deps = deriveDeps(p, t);
const menuOpen = menu === `row:${p.id}`;
const menuBtnRef = useRef<HTMLButtonElement>(null);
const working = busy === p.id;
const hostCount = p.egressHostCount ?? 0;
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
useFollowTrigger(menuOpen);
const notices: Notice[] = [];
if (p.status === 'error' && p.last_error) {
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
}
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
// kept until resolved rather than dying with the toast that first reported it.
if (blocked && p.updateBlock) {
notices.push({
key: 'block',
tone: 'warning',
icon: ShieldAlert,
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
});
}
for (const [i, d] of deps.entries()) {
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
}
// Held: the admin deliberately installed a non-latest version, so updates are paused
// rather than nagged — resuming is one click, right where the pause shows.
if (p.updateHold) {
notices.push({
key: 'hold',
tone: 'neutral',
icon: PauseCircle,
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
});
}
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
if (newerIncompatible) {
notices.push({
key: 'newer',
tone: 'neutral',
icon: Info,
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
});
}
return (
<div
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
/>
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
{p.name}
</span>
{p.version && (
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
v{p.version}
</span>
)}
</div>
{p.description && (
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
{p.description}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
{c.label}
</Fact>
))}
{deps
.filter((d) => !d.blocked && !d.warn)
.map((d, i) => (
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
{d.label}
</Fact>
))}
{p.reviewed_at && (
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
{t('admin.plugins.reviewed')}
</Fact>
)}
{p.source_repo === 'local:upload' && (
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
{t('admin.plugins.sideloaded')}
</Fact>
)}
{p.source_repo === 'local:link' && (
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
{t('admin.plugins.devLinkBadge')}
</Fact>
)}
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
{isRegistrySourced(p.source_repo) &&
(p.signed ? (
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
{t('admin.plugins.signed')}
</Fact>
) : (
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
{t('admin.plugins.unsigned')}
</Fact>
))}
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
{notices.length > 0 && (
<div className="mt-2.5 flex flex-col gap-1.5">
{notices.map((n) => (
<NoticeLine key={n.key} n={n} />
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button
type="button"
onClick={props.onUpdate}
disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}
>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
style={fs(12)}
>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button
type="button"
ref={menuBtnRef}
data-testid={`plugin-row-menu-btn-${p.id}`}
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen}
aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen &&
createPortal(
<div
data-testid={`plugin-row-menu-${p.id}`}
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]"
>
{p.enabled === 1 && (
<MenuItem
icon={<RotateCw size={14} />}
label={t('admin.plugins.restart')}
onClick={props.onRestart}
/>
)}
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem
icon={<SlidersHorizontal size={14} />}
label={t('admin.plugins.instanceSettings')}
onClick={props.onSettings}
/>
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
{p.operatorEgress && (
<MenuItem
icon={<Globe size={14} />}
label={t('admin.plugins.allowedHosts')}
onClick={props.onEgress}
/>
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem
icon={<History size={14} />}
label={t('admin.plugins.changeVersion')}
onClick={props.onChangeVersion}
/>
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a
href={`https://github.com/${p.source_repo}`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<Github size={14} />
</span>{' '}
{t('admin.plugins.sourceRepo')}
</a>
<a
href={`https://github.com/${p.source_repo}/issues`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<CircleDot size={14} />
</span>{' '}
{t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
</div>,
document.body
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { ShieldCheck } from 'lucide-react';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import type { T } from './pluginModel';
/**
* How plugins are contained and where that ends, opened from the shield in the panel's
* header. It used to sit under the list as a card of its own, which pushed a wall of
* text into the page every admin scrolls past.
*/
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
const sections: Array<[string, string]> = [
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
];
return (
<DialogShell
onClose={onClose}
labelledBy="plugin-security-title"
width="detail"
header={
<DialogHeader
tile={
<DialogTile>
<ShieldCheck size={20} className="text-content-secondary" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId="plugin-security-title"
title={t('admin.plugins.security.short')}
sub={t('admin.plugins.reviewedMeaning')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.ok')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
{h}
</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
{b}
</p>
</div>
))}
</div>
</DialogShell>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState, type CSSProperties } from 'react';
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
const ROW_MENU_MAX_H = 260;
/**
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
* window has no room below. Fixed, because the cards around the toolbar and the rows
* clip whatever overflows them.
*/
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect();
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
};
}
/** One entry of a popover menu, in the packing popovers' shape. */
export const MENU_ITEM =
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
export function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0);
useEffect(() => {
if (!open) return;
const onMove = () => reanchor((n) => n + 1);
window.addEventListener('scroll', onMove, true);
window.addEventListener('resize', onMove);
return () => {
window.removeEventListener('scroll', onMove, true);
window.removeEventListener('resize', onMove);
};
}, [open]);
}
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
blocked: false,
version: '0.9.0',
});
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
expect(installOffer(incompatible, t)).toMatchObject({
blocked: false,
label: 'admin.plugins.install',
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
});
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
});
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
@@ -298,7 +298,8 @@ export function installOffer(
title,
};
}
return { blocked: true, label: t('admin.plugins.incompatible'), title };
// No version fits this TREK: the admin may still install this one plugin at their own risk.
return bypassOffer(item, t, title, true);
}
/**
@@ -1,4 +1,4 @@
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { DragEvent } from 'react';
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
expect(result.current.confirmUninstall).toBeNull();
});
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
serve([
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
]);
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
const { result } = await mounted();
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
expect(result.current.bypass.warning).toMatchObject({
acceptedByAdmin: true,
action: 'activate',
trekRange: '>=3.2.0 <4.0.0',
});
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
});
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
serve([], [entry({ id: 'beta', name: 'Beta' })]);
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
const { result } = await mounted();
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
act(() => result.current.install('beta', undefined, warn));
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
});
});
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
act(
id,
() =>
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
adminApi
.pluginInstall(id, {
...(version ? { version } : {}),
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
})
.then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
t('admin.plugins.installed')
)
);
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
}
};
const attemptActivate = (p: PluginRow) => {
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
const cascaded = autoEnabledDeps(p);
return adminApi
.pluginActivate(p.id)
.pluginActivate(p.id, false, acceptTrekRange)
.then(() => {
toast.success(t('admin.plugins.activated'));
if (cascaded.length)
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
return;
}
setBusy(p.id);
setMenu(null);
attemptActivate(p).finally(() => {
setBusy(null);
refresh();
});
const run = (acceptTrekRange: boolean) => {
setBusy(p.id);
setMenu(null);
attemptActivate(p, acceptTrekRange).finally(() => {
setBusy(null);
refresh();
});
};
// Outside the TREK versions its author declared: the admin may run it anyway, after
// reading what that risks. The choice holds for this version of the plugin only.
if (p.dependencyStatus === 'hostIncompatible') {
const warn: RangeWarning = {
name: p.name,
trekRange: p.trekRange ?? null,
hostVersion: p.hostVersion ?? '?',
acceptedByAdmin: true,
onConfirm: null,
action: 'activate',
};
bypass.guard(warn, () => run(true));
return;
}
run(false);
};
// Download a missing/outdated plugin dependency (latest compatible for its range,
@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest';
import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel';
import {
TREK_API_SOURCES,
trekApiAddressFeatures,
trekApiFacts,
trekApiFields,
trekApiRoutingFeatures,
} from './trekApiModel';
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-004
const t = (key: string) => `<${key}>`;
@@ -22,10 +28,12 @@ describe('trekApiModel', () => {
]);
});
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
it('FE-TREKAPIMODEL-002: leads with the three services, then the terms, each with a glyph', () => {
const facts = trekApiFacts(t);
expect(facts.map((f) => f.text)).toEqual([
'<admin.trekApi.factPlaces>',
'<admin.trekApi.factAddresses>',
'<admin.trekApi.factRouting>',
'<admin.trekApi.factNoKey>',
'<admin.trekApi.factOffline>',
'<admin.trekApi.factPrivacy>',
@@ -34,6 +42,28 @@ describe('trekApiModel', () => {
});
it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => {
expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']);
expect(TREK_API_SOURCES).toEqual([
'Overture Maps Foundation',
'OpenStreetMap',
'Wikivoyage',
'Wikimedia',
'Mapzen Terrain Tiles',
]);
});
it('FE-TREKAPIMODEL-004: lists what the address search and the routing answer', () => {
expect(trekApiAddressFeatures(t)).toEqual([
'<admin.trekApi.addrStreets>',
'<admin.trekApi.addrTowns>',
'<admin.trekApi.addrReverse>',
'<admin.trekApi.addrLanguages>',
]);
expect(trekApiRoutingFeatures(t)).toEqual([
'<admin.trekApi.routeCar>',
'<admin.trekApi.routeBike>',
'<admin.trekApi.routeFoot>',
'<admin.trekApi.routeAvoid>',
'<admin.trekApi.routeElevation>',
]);
});
});
+33 -4
View File
@@ -1,13 +1,19 @@
import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react';
import { Globe2, KeyRound, Route, Search, ShieldOff, WifiOff } from 'lucide-react';
import type { TranslationFn } from '../../types';
/**
* Proper nouns, so they are not translated. Naming them is also a licence obligation,
* not decoration: ODbL and CC BY-SA both require attribution wherever their content is
* shown.
* shown, and the terrain tiles the routes take their heights from ask to be named too.
*/
export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'];
export const TREK_API_SOURCES = [
'Overture Maps Foundation',
'OpenStreetMap',
'Wikivoyage',
'Wikimedia',
'Mapzen Terrain Tiles',
];
/**
* The fields a TREK Places API result carries, for the desktop card and the phone block
@@ -29,10 +35,33 @@ export function trekApiFields(t: TranslationFn): string[] {
];
}
/** The four facts the TREK Places API block leads with, each with its glyph. */
/** What the address search answers: an address typed, a town, the address of a point. */
export function trekApiAddressFeatures(t: TranslationFn): string[] {
return [
t('admin.trekApi.addrStreets'),
t('admin.trekApi.addrTowns'),
t('admin.trekApi.addrReverse'),
t('admin.trekApi.addrLanguages'),
];
}
/** What the routing does: the three ways of getting there, what a drive can avoid, the heights. */
export function trekApiRoutingFeatures(t: TranslationFn): string[] {
return [
t('admin.trekApi.routeCar'),
t('admin.trekApi.routeBike'),
t('admin.trekApi.routeFoot'),
t('admin.trekApi.routeAvoid'),
t('admin.trekApi.routeElevation'),
];
}
/** The facts the TREK API block leads with, each with its glyph: the three services first, then the terms. */
export function trekApiFacts(t: TranslationFn) {
return [
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
{ Icon: Search, text: t('admin.trekApi.factAddresses') },
{ Icon: Route, text: t('admin.trekApi.factRouting') },
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
@@ -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 };
}
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
label: 'admin.plugins.dep.trekAcceptedUnknown',
})
})
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
blocked: false, label: 'admin.plugins.install', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
})
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
})
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
})
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
expect(rangeWarningCopy(mine, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
confirmLabel: 'admin.plugins.activateAnyway',
})
})
})
+61 -22
View File
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
type T = (k: string, p?: Record<string, unknown>) => string
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
/**
* The server's "this only went through because the version check was set aside" marker:
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
* TREK_PLUGINS_IGNORE_TREK_RANGE.
*/
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
/**
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
* notice). `action` names what the confirm button does: install (the default) or activate.
*/
export interface RangeWarning extends TrekRangeBypass {
name: string
onConfirm: (() => void) | null
action?: 'install' | 'activate'
}
/**
* The chip a row carries while it runs outside its declared range on the operator's
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
*/
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null
const label = b.acceptedByAdmin
? b.trekRange
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekAcceptedUnknown')
: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown')
return {
icon: AlertTriangle,
label: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
label,
blocked: false,
warn: true,
}
}
/**
* The Install button for a registry entry the server flagged incompatible, once the
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
* regardless, so the button says exactly that — and carries what the warning has to say
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
* The Install button for a registry entry the server flagged incompatible: the server will
* take the newest version regardless, so the button says exactly that — and carries what
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
*/
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
return {
blocked: false,
label: t('admin.plugins.installAnyway'),
title,
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
export function bypassOffer(
item: { name: string; trek?: string | null; hostVersion?: string },
t: T,
title: string,
byAdmin = false
) {
const warn: RangeWarning = {
name: item.name,
trekRange: item.trek ?? null,
hostVersion: item.hostVersion ?? '?',
acceptedByAdmin: byAdmin,
onConfirm: null,
}
return { blocked: false, label: t('admin.plugins.install'), title, warn }
}
/**
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
* whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data.
*/
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
export function rangeWarningCopy(
w: RangeWarning,
t: T
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
const confirm = w.onConfirm !== null
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
const body = w.acceptedByAdmin
? w.trekRange
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
: w.trekRange
? t('admin.plugins.rangeBypass.body', vars)
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
const title = w.acceptedByAdmin
? t('admin.plugins.rangeBypass.titleAdmin')
: confirm
? t('admin.plugins.rangeBypass.title')
: t('admin.plugins.rangeBypass.noticeTitle')
return {
confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
body: w.trekRange
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
title,
body,
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
}
}
@@ -0,0 +1,91 @@
// FE-ADMIN-SVCID-HOOK-001 to FE-ADMIN-SVCID-HOOK-004
import { act, renderHook } from '@testing-library/react';
import type { ServiceIdentityStatus } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useServiceIdentity } from './useServiceIdentity';
const { status, regenerate, visible } = vi.hoisted(() => ({
status: vi.fn(),
regenerate: vi.fn(),
visible: { current: true },
}));
vi.mock('../../repo/serviceIdentityRepo', () => ({ serviceIdentityRepo: { status, regenerate } }));
vi.mock('../../hooks/useElementVisible', () => ({ useElementVisible: () => visible.current }));
const LEGACY: ServiceIdentityStatus = {
state: 'legacy',
tier: 'legacy',
keySource: null,
services: [],
lastSeenAt: null,
};
const card = { current: null };
beforeEach(() => {
vi.useFakeTimers();
visible.current = true;
status.mockReset().mockResolvedValue(LEGACY);
regenerate.mockReset();
});
afterEach(() => vi.useRealTimers());
describe('useServiceIdentity', () => {
it('FE-ADMIN-SVCID-HOOK-001: reads once in view, then every 30 seconds', async () => {
const { result } = renderHook(() => useServiceIdentity(card));
await act(async () => {});
expect(result.current.status).toEqual(LEGACY);
expect(status).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(status).toHaveBeenCalledTimes(2);
});
it('FE-ADMIN-SVCID-HOOK-002: reads nothing while the card is out of view, and stops when it leaves', async () => {
visible.current = false;
const { rerender } = renderHook(() => useServiceIdentity(card));
await act(async () => {
await vi.advanceTimersByTimeAsync(90_000);
});
expect(status).not.toHaveBeenCalled();
visible.current = true;
rerender();
await act(async () => {});
expect(status).toHaveBeenCalledTimes(1);
visible.current = false;
rerender();
await act(async () => {
await vi.advanceTimersByTimeAsync(90_000);
});
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-SVCID-HOOK-003: marks a failed read, and clears the mark on the next good one', async () => {
status.mockRejectedValueOnce(new Error('offline'));
const { result } = renderHook(() => useServiceIdentity(card));
await act(async () => {});
expect(result.current.failed).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(result.current.failed).toBe(false);
});
it('FE-ADMIN-SVCID-HOOK-004: a new identity replaces the status, and a refusal reaches the caller', async () => {
const registering = { ...LEGACY, state: 'registering' as const };
regenerate.mockResolvedValueOnce(registering).mockRejectedValueOnce(new Error('409'));
const { result } = renderHook(() => useServiceIdentity(card));
await act(async () => {
await result.current.regenerate();
});
expect(result.current.status).toEqual(registering);
await act(async () => {
await expect(result.current.regenerate()).rejects.toThrow('409');
});
expect(result.current.regenerating).toBe(false);
});
});
@@ -0,0 +1,51 @@
import type { ServiceIdentityStatus } from '@trek/shared';
import { useCallback, useEffect, useState, type RefObject } from 'react';
import { useElementVisible } from '../../hooks/useElementVisible';
import { serviceIdentityRepo } from '../../repo/serviceIdentityRepo';
const REFRESH_MS = 30_000;
/**
* The installation's standing with the TREK services for the admin card:
* read when the card comes into view, then every 30 seconds while it stays
* there. Admin configuration, online only like the rest of the settings tab.
*/
export function useServiceIdentity(card: RefObject<Element | null>) {
const visible = useElementVisible(card);
const [status, setStatus] = useState<ServiceIdentityStatus | null>(null);
const [failed, setFailed] = useState(false);
const [regenerating, setRegenerating] = useState(false);
useEffect(() => {
if (!visible) return;
const controller = new AbortController();
const refresh = () => {
serviceIdentityRepo.status(controller.signal).then(
(next) => {
setStatus(next);
setFailed(false);
},
() => {
if (!controller.signal.aborted) setFailed(true);
}
);
};
refresh();
const timer = setInterval(refresh, REFRESH_MS);
return () => {
controller.abort();
clearInterval(timer);
};
}, [visible]);
const regenerate = useCallback(async (): Promise<void> => {
setRegenerating(true);
try {
setStatus(await serviceIdentityRepo.regenerate());
} finally {
setRegenerating(false);
}
}, []);
return { status, failed, regenerating, regenerate };
}
@@ -30,6 +30,7 @@ export default function AssistantSurface({
onRevealMap,
bookVersion,
onOpenTripChat,
journal,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
@@ -43,8 +44,9 @@ export default function AssistantSurface({
onRevealMap,
bookVersion,
onOpenTripChat,
journal,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -0,0 +1,36 @@
/**
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
*
* The empty chat asks what fits the chat it is: a journey's conversation
* shown for its journal asks about the journal, the same conversation in TREK
* Studio about the book, and each tells a reader what they may not change.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantWelcome from './AssistantWelcome';
import { AssistantViewContext } from './assistantViewContext';
const JOURNAL_KEY = -12;
function welcome(canEdit: boolean, journal?: boolean) {
return render(
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
</AssistantViewContext.Provider>
);
}
describe('AssistantWelcome', () => {
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
welcome(false, true);
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
});
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
welcome(true);
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
});
});
@@ -27,33 +27,40 @@ export default function AssistantWelcome({
withSuggestions: boolean;
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
const scope = useAssistantScope().kind;
const book = scope === 'journey';
const { tripId, variant, journal } = useAssistantView();
// The Studio's chat is about a book, not a plan; the journey's chat shown for its journal about the journal,
// the home chat about all of the user's travels.
const about = useAssistantScope().kind;
const scope: WelcomeScope = about === 'journey' && journal ? 'journal' : about;
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
</p>
)}
{!canEdit && <p className="m-0 max-w-xs text-center text-caption text-content-muted">{readOnlyNote(scope, t)}</p>}
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
</div>
);
}
/** What an empty chat is about: a trip, a journey's book in the Studio, a journal on its page, or all of them. */
type WelcomeScope = 'trip' | 'journey' | 'journal' | 'home';
/** The question over the empty chat, by what the chat is about. */
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
function welcomeTitle(scope: WelcomeScope, t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.welcome');
if (scope === 'journal') return t('assistant.journal.welcome');
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
}
/** What the empty chat tells someone who may only read. */
function readOnlyNote(scope: WelcomeScope, t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.readOnly');
return scope === 'journal' ? t('assistant.journal.readOnly') : t('assistant.empty.readOnly');
}
export function AssistantSuggestions({
suggestions,
layout,
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader';
@@ -15,8 +15,9 @@ import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantStatus } from './useAssistantStatus';
import { useAssistantThread } from './useAssistantThread';
/**
@@ -33,7 +34,9 @@ import { useAssistantThread } from './useAssistantThread';
*
* Outside a trip it opens on the home chat, which knows all of the user's
* trips; its head lists them to go on in one trip's own chat, and a trip's
* chat opened that way has the way back home in its head.
* chat opened that way has the way back home in its head. On a journey's
* page it opens on that journal's chat, the journey's conversation it shares
* with TREK Studio.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
@@ -170,26 +173,42 @@ function ChatWindow({
onFocusDay={onFocusDay}
onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
// The window never shows the Studio: a journey's chat in it is the journal's.
journal={assistantScopeOf(trip.id).kind === 'journey'}
/>
</div>
</section>
);
}
/**
* Which chat the window shows: the page's own (the trip in the planner, else
* the journal on screen), else the one picked, else the home chat. `onScreen`
* when it is the page's own, `inPlanner` when that page is the planner.
*/
function windowChat(
trip: WindowTrip | null,
journal: WindowTrip | null,
picked: WindowTrip | null,
homeChat: WindowTrip
) {
const routeChat = trip ?? journal;
const current = routeChat ?? picked ?? homeChat;
const onScreen = !!routeChat && current.id === routeChat.id;
return { routeChat, current, onScreen, inPlanner: onScreen && trip !== null };
}
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: AssistantWindowProps) {
const navigate = useNavigate();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantStatus();
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
@@ -198,13 +217,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!open) setPicked(null);
}
const current = trip ?? picked ?? home.chat;
const journal = useRouteJournalChat();
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
const atHome = current.id === HOME_CHAT_KEY;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
},
[navigate, picked]
);
@@ -216,19 +236,20 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
[navigate]
);
const goHome = useCallback(() => setPicked(null), []);
// Another page leaves the trip: the chat stays on it, as if it had been picked.
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page' && trip) setPicked(trip);
if (target.kind === 'page' && routeChat) setPicked(routeChat);
onOpen?.(target);
},
[onOpen, trip]
[onOpen, routeChat]
);
// The round button's hint dot reads a trip's plan; a journal has none.
const launcherTrip = trip?.id ?? null;
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
const onScreen = !!trip && current.id === trip.id;
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
if (atHome) openTarget = openFromHome;
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
@@ -241,13 +262,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
hidden={minimized}
onOpen={openTarget}
// Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : undefined}
onFocusDay={inPlanner ? onFocusDay : undefined}
onRevealMap={inPlanner ? onRevealMap : undefined}
onOpenTripChat={atHome ? home.openTripChat : undefined}
tripSwitch={tripSwitch}
/>
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
</>
);
}
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
it('FE-AI-FOLLOW-008: the answer to an attached file offers what the server chose, also under its booking cards', () => {
const read = {
type: 'tool-call' as const,
toolCallId: 'r',
toolName: 'read_booking_file',
args: { name: 'AirBnB Buchung.pdf' },
result: { bookings: [], reason: 'ai_not_configured' },
};
const chips = {
type: 'data-trek-follow-ups' as const,
data: { questions: ['Keep it in Files', 'What is in the file?'] },
};
const empty = of([
read,
{ type: 'text', text: 'I could not find a booking. What should I do with the file?' },
chips,
]);
expect(followUpsFor(empty, true).map((f) => f.text)).toEqual(['Keep it in Files', 'What is in the file?']);
const change = {
toolCallId: 'b',
verbKey: 'importBooking',
subject: 'Casa',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
};
const found = of([
read,
{ type: 'text', text: 'I found 1 booking.' },
{ type: 'data-trek-change', data: change as AssistantChangePreview },
{ type: 'data-trek-follow-ups', data: { questions: ['Keep the file with this booking'] } },
]);
expect(followUpsFor(found, true).map((f) => f.text)).toEqual(['Keep the file with this booking']);
});
});
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' },
title: { labelKey: 'assistant.change.label.title' },
content: { labelKey: 'assistant.change.label.content' },
icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' },
@@ -463,6 +465,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
sentTo: { labelKey: 'assistant.change.label.sentTo' },
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
};
/**
@@ -39,6 +39,12 @@ export interface AssistantView {
* chat's card that offers it; left out where no chat can be switched to.
*/
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
/**
* A journey's chat shown for its journal (on the journey's page, from the
* home chat), not for its book in TREK Studio; left out in the Studio and
* for a trip. The empty chat then speaks of the journal.
*/
journal?: boolean;
}
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
riso: { labelKey: 'studio.theme.name.riso' },
magazine: { labelKey: 'studio.theme.name.magazine' },
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
midnight: { labelKey: 'studio.theme.name.midnight' },
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
}
});
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
});
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
@@ -5,6 +5,9 @@ import {
CalendarMinus,
CalendarPlus,
Circle,
FileSearch,
FileText,
FolderInput,
ListTodo,
Luggage,
MapPin,
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
...TOUR_LOOK.steps,
...ROADTRIP_LOOK.steps,
...BUDGET_LOOK.steps,
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
assignment: { Icon: CalendarDays, onMap: BY_DAY },
day_note: { Icon: StickyNote, onMap: BY_DAY },
memory: { Icon: NotebookPen },
// A file kept from the chat: it opens the trip's Files.
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
other: { Icon: Circle },
...TOUR_LOOK.entities,
...ROADTRIP_LOOK.entities,
@@ -0,0 +1,49 @@
import { ArrowRight, MessageSquareText } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type AssistantChatTrip, useAssistantView } from '../../assistantViewContext';
/**
* What the home chat's cards that go on in another chat share: the question
* handed over, waiting there ready to send, and the button that switches to
* that chat. The button shows only where a chat can be switched to.
*/
/** The question that waits in the other chat's composer. */
export function HandedQuestion({ question }: { question: string | null }) {
const { t } = useTranslation();
if (!question) return null;
return (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
);
}
/** The button that goes on in the chat under `chat`, with the question handed over. */
export function OpenChatButton({
chat,
question,
label,
}: {
chat: AssistantChatTrip;
question: string | null;
label: string;
}) {
const { onOpenTripChat, variant } = useAssistantView();
if (!onOpenTripChat) return null;
return (
<button
type="button"
onClick={() => onOpenTripChat(chat, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{label}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
);
}
@@ -0,0 +1,72 @@
/**
* FE-AI-JOURNALCHAT-001 to FE-AI-JOURNALCHAT-003
*
* The home chat's card that offers a journal's own chat: the journal with
* its subtitle or days and what it holds, the question waiting there, and the
* button that goes on in the chat kept under the journey's key.
*/
import type { JournalChatCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { AssistantViewContext } from '../../assistantViewContext';
import { JournalChatBlock } from './JournalChatBlock';
const JAPAN: JournalChatCard = {
kind: 'journalChat',
journey: {
id: 12,
title: 'Japan 2026',
subtitle: null,
startDate: '2026-11-02',
endDate: '2026-11-16',
entryCount: 4,
photoCount: 120,
},
question: 'Make a new entry for Berlin Hauptbahnhof',
};
function drawn(
cards: JournalChatCard[],
onOpenTripChat?: (chat: { id: number; title: string }, q: string | null) => void
) {
return render(
<AssistantViewContext.Provider value={{ tripId: 0, variant: 'fullscreen', onOpenTripChat }}>
<JournalChatBlock cards={cards.map((card, index) => ({ toolCallId: `j${index}`, card }))} />
</AssistantViewContext.Provider>
);
}
describe('JournalChatBlock', () => {
it('FE-AI-JOURNALCHAT-001: shows the journal, its days, what it holds and the question', () => {
drawn([JAPAN]);
expect(screen.getByRole('region', { name: 'Continue in a journal' })).toBeInTheDocument();
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
expect(screen.getByText('4 entries')).toBeInTheDocument();
expect(screen.getByText('120 photos')).toBeInTheDocument();
expect(screen.getByText('Make a new entry for Berlin Hauptbahnhof')).toBeInTheDocument();
// Nowhere to switch to: no button.
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it("FE-AI-JOURNALCHAT-002: the button goes on in the journal's chat, under the journey's key", () => {
const open = vi.fn();
drawn([JAPAN], open);
fireEvent.click(screen.getByRole('button', { name: "Continue in this journal's chat" }));
expect(open).toHaveBeenCalledWith({ id: -12, title: 'Japan 2026' }, 'Make a new entry for Berlin Hauptbahnhof');
});
it('FE-AI-JOURNALCHAT-003: a subtitle stands for the days, an empty journal shows no photos, nothing draws nothing', () => {
const bare: JournalChatCard = {
...JAPAN,
journey: { ...JAPAN.journey, subtitle: 'Autumn', startDate: null, endDate: null, entryCount: 0, photoCount: 0 },
question: null,
};
drawn([bare]);
expect(screen.getByText('Autumn')).toBeInTheDocument();
expect(screen.getByText('0 entries')).toBeInTheDocument();
expect(screen.queryByText(/photos?$/)).not.toBeInTheDocument();
const { container } = render(<JournalChatBlock cards={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,76 @@
import type { JournalChatCard } from '@trek/shared';
import { BookOpen } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { assistantChatKey } from '../../../../store/assistantChatKey';
import { WhiteBadge } from '../../../Planner/planParts';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
import { chatDates } from './chatDates';
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
/**
* The home chat's way onto a journal's own page: the journal under its head
* (its subtitle or days, how many entries and photos it holds), the question
* that waits there ready to send, and the button that goes on in the
* journal's chat. That chat is the journey's conversation on its page, kept
* under the journey's key; the window switches to it in place, the phone
* opens the journal with its chat.
*/
function JournalFacts({ card }: { card: JournalChatCard }) {
const { t } = useTranslation();
const { entryCount, photoCount } = card.journey;
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<WhiteBadge>{t('assistant.card.journey.entries', { count: entryCount })}</WhiteBadge>
{photoCount > 0 && <WhiteBadge>{t('assistant.card.journey.photos', { count: photoCount })}</WhiteBadge>}
</div>
);
}
function JournalChatSection({ card, first }: { card: JournalChatCard; first: boolean }) {
const { t } = useTranslation();
const format = useCardFormat();
const { journey, question } = card;
const meta = journey.subtitle ?? chatDates(t, format.date, journey.startDate, journey.endDate);
const chat = { id: assistantChatKey({ kind: 'journey', journeyId: journey.id }), title: journey.title };
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
{first ? (
<CardHeader
icon={BookOpen}
tone="info"
eyebrow={t('assistant.card.journalChat.eyebrow')}
title={journey.title}
meta={meta}
>
<JournalFacts card={card} />
</CardHeader>
) : (
<div className="mb-2.5">
<bdi className="block truncate text-body font-bold text-content">{journey.title}</bdi>
<div className="mt-0.5 text-caption text-content-muted">{meta}</div>
<JournalFacts card={card} />
</div>
)}
<HandedQuestion question={question} />
<OpenChatButton chat={chat} question={question} label={t('assistant.card.journalChat.open')} />
</div>
);
}
export function JournalChatBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const journals = cards.flatMap((entry) => (entry.card.kind === 'journalChat' ? [entry.card] : []));
if (journals.length === 0) return null;
return (
<CardFrame label={t('assistant.card.journalChat.label')}>
{journals.map((card, index) => (
<JournalChatSection key={`${card.journey.id}-${index}`} card={card} first={index === 0} />
))}
</CardFrame>
);
}
@@ -1,13 +1,14 @@
import type { TripChatCard, TripChatPhase } from '@trek/shared';
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
import { Luggage } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
import { chatDates } from './chatDates';
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
/**
* The home chat's way into one trip's own chat: the trip under its head (its
@@ -27,18 +28,9 @@ const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: stri
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
const { t } = useTranslation();
const { onOpenTripChat, variant } = useAssistantView();
const format = useCardFormat();
const { trip, question } = card;
const start = trip.startDate ?? trip.endDate;
const end = trip.endDate ?? trip.startDate;
let dates = t('assistant.card.tripChat.noDates');
if (start && end) {
dates =
start === end
? format.date(start)
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
}
const dates = chatDates(t, format.date, trip.startDate, trip.endDate);
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
@@ -59,25 +51,12 @@ function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }
<TripFacts phase={phase} placeCount={trip.placeCount} />
</div>
)}
{question && (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
)}
{onOpenTripChat && (
<button
type="button"
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{t('assistant.card.tripChat.open')}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
)}
<HandedQuestion question={question} />
<OpenChatButton
chat={{ id: trip.id, title: trip.title }}
question={question}
label={t('assistant.card.tripChat.open')}
/>
</div>
);
}
@@ -0,0 +1,17 @@
/**
* The days a card of another chat names under its head: one day, a span, or
* that it has none yet. Pure, so the trip's card and the journal's read one
* wording.
*/
export function chatDates(
t: (key: string, params?: Record<string, string>) => string,
date: (value: string) => string,
startDate: string | null,
endDate: string | null
): string {
const start = startDate ?? endDate;
const end = endDate ?? startDate;
if (!start || !end) return t('assistant.card.tripChat.noDates');
if (start === end) return date(start);
return t('assistant.card.tripChat.dates', { start: date(start), end: date(end) });
}
@@ -13,9 +13,11 @@ export const HOME_LOOK = {
steps: {
list_trips: { stepKey: 'assistant.step.readingTrips' },
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
open_journey_chat: { stepKey: 'assistant.step.openJournalChat' },
},
entities: {},
cards: {
tripChat: {},
journalChat: {},
},
} satisfies PackLook<'tripChat'>;
} satisfies PackLook<'tripChat' | 'journalChat'>;
@@ -1,12 +1,14 @@
import type { CardView } from '../cardTypes';
import { JournalChatBlock } from './JournalChatBlock';
import { TripChatBlock } from './TripChatBlock';
/**
* How the home chat's kind draws: the trip it offers to go on with, beside
* the answer. This file only maps the kind to its view; the component lives
* How the home chat's kinds draw: the trip or the journal it offers to go on
* with, beside the answer. This file only maps the kind to its view; the component lives
* in a sibling file, since a file that exports anything besides components
* breaks fast refresh.
*/
export const HOME_VIEWS = {
tripChat: { Block: TripChatBlock },
} satisfies { [K in 'tripChat']: CardView<K> };
journalChat: { Block: JournalChatBlock },
} satisfies { [K in 'tripChat' | 'journalChat']: CardView<K> };
@@ -1,7 +1,20 @@
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
import {
AlignLeft,
ArrowDownUp,
CalendarDays,
Clock,
LocateFixed,
MapPin,
Tag,
Tags,
ThumbsDown,
ThumbsUp,
Type,
} from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel } from '../../../Journey/journeyCard';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
/** The facts a journal change row names on its faint line, in this order: when, then where. */
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
/** The words an update rewrites, named only when they change: the entry's title and its text. */
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
const was = stateOf(row.field, row.before);
const now = stateOf(row.field, row.after);
const Icon = (now ?? was)?.Icon ?? Tag;
const listed =
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
return (
<FactChange
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
);
}
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
function NewOrder({ card }: { card: JourneyEntriesCard }) {
const { t, locale } = useTranslation();
return (
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
{card.entries.map((look, index) => (
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
</li>
))}
{card.more > 0 && (
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
)}
</ol>
);
}
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
const { t } = useTranslation();
const hover = useEntryHover(card.journeyId, item.toolCallId);
const look = card.entries[0];
if (!look || GONE.has(item.state)) return null;
if (card.change === 'reordered') return <NewOrder card={card} />;
return (
<div {...entryHoverProps(hover, look)}>
<EntryStory look={look} roomy={false} />
@@ -0,0 +1,100 @@
/**
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
*
* The parts of a journal entry the chat added later: its tags and how its
* verdict leans among its marks, an update's tags, verdict and coordinates
* old to new, and a new order of the entries drawn as the entries that move.
*/
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../../tests/helpers/render';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
const ENTRY: JourneyEntryLook = {
entryId: 12,
type: 'entry',
title: 'Sunset at the Spree',
date: '2026-10-21',
time: null,
place: null,
point: null,
countryCode: null,
story: null,
mood: null,
weather: null,
photoId: null,
video: false,
photoCount: 0,
visibility: 'private',
draft: false,
fromTripPlace: false,
dayIndex: null,
tags: ['river', 'evening'],
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
};
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
}
function pending(
card: JourneyEntriesCard,
verbKey: AssistantChangePreview['verbKey'],
rows: AssistantChangePreview['rows']
) {
const preview: AssistantChangePreview = {
toolCallId: 'e1',
verbKey,
subject: 'Berlin',
rows,
dayId: null,
placeIds: [],
undoable: true,
domain: 'journey',
card,
};
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
return render(
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('the later parts of a journal entry', () => {
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
expect(screen.getByText('river')).toBeInTheDocument();
expect(screen.getByText('evening')).toBeInTheDocument();
expect(screen.getByText('1 good point')).toBeInTheDocument();
expect(screen.getByText('2 weak points')).toBeInTheDocument();
});
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
const rows: AssistantChangePreview['rows'] = [
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
];
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
expect(screen.getByText('river, evening')).toHaveClass('line-through');
expect(screen.getByText('cold')).toBeInTheDocument();
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
});
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
const moved = [
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
];
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
const text = document.body.textContent ?? '';
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
expect(screen.getByText('3 more')).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import type { JourneyEntryLook } from '@trek/shared';
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
@@ -101,11 +101,42 @@ function WeatherMark({ weather }: { weather: string | null }) {
);
}
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
/** Its tags and how its verdict leans: the good and the weak points counted. */
function ListMarks({ look }: { look: JourneyEntryLook }) {
const { t } = useTranslation();
const verdict = look.verdict ?? null;
return (
<>
{(look.tags ?? []).map((tag, index) => (
<SoftPill
key={`${tag}-${index}`}
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
>
<bdi className="truncate">{tag}</bdi>
</SoftPill>
))}
{verdict && verdict.prosCount > 0 && (
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
</SoftPill>
)}
{verdict && verdict.consCount > 0 && (
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.journey.cons', { count: verdict.consCount })}
</SoftPill>
)}
</>
);
}
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
const { t } = useTranslation();
const mood = moodOf(look.mood) !== null;
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
return null;
}
return (
<CardFacts>
<VisibilityMark visibility={look.visibility} />
@@ -117,6 +148,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
{t('assistant.card.journey.photos', { count: look.photoCount })}
</SoftPill>
)}
<ListMarks look={look} />
</CardFacts>
);
}
@@ -0,0 +1,151 @@
/**
* FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005
*
* A change of a journal itself in the chat: the settings an update sets by
* the journal's own names, old to new; the trips under the row with the one
* the change links or unlinks marked; what a new journal brings and what a
* deletion or an unlink takes; the receipt that opens the journal.
*/
import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
import { headerValueKey, settingRows } from './journeyHeaderModel';
const ASSISTANT = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
});
const TOKYO: JourneyHeaderTrip = {
id: 5,
title: 'Tokyo days',
startDate: '2026-11-02',
endDate: '2026-11-08',
change: 'kept',
};
const JAPAN: JourneyHeaderCard = {
kind: 'journeyHeader',
journeyId: 12,
title: 'Japan 2026',
subtitle: null,
change: 'changed',
trips: [TOKYO],
entries: 3,
photos: 40,
suggestions: null,
};
function change(
state: ChangeItem['state'],
card: JourneyHeaderCard,
over: Partial<AssistantChangePreview> = {}
): ChangeItem {
const preview: AssistantChangePreview = {
toolCallId: 'h1',
verbKey: 'updateJournalSettings',
subject: 'Japan 2026',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
domain: 'journey',
card,
...over,
};
const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null };
return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card };
}
function shown(item: ChangeItem, onOpen?: (target: unknown) => void) {
return render(
<AssistantViewContext.Provider value={{ tripId: -12, variant: 'window', onOpen }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('a change of a journal itself', () => {
it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => {
const rows: AssistantChangePreview['rows'] = [
{ op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' },
{ op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' },
{ op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' },
];
shown(change('pending', JAPAN, { rows }));
expect(screen.getByText("Change the journal's settings")).toBeInTheDocument();
expect(screen.getByText('Japan')).toHaveClass('line-through');
expect(screen.getByText('Mood: On')).toHaveClass('line-through');
expect(screen.getByText('Mood: Off')).toBeInTheDocument();
expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through');
expect(screen.getByText('Shown as: Live')).toBeInTheDocument();
expect(screen.getByText('Tokyo days')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => {
const fresh: JourneyHeaderCard = {
...JAPAN,
journeyId: null,
change: 'new',
subtitle: 'Autumn',
trips: [{ ...TOKYO, change: 'link' }],
entries: null,
photos: null,
suggestions: 9,
};
shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true }));
expect(screen.getByText('Start a journal')).toBeInTheDocument();
expect(screen.getByText('Autumn')).toBeInTheDocument();
expect(screen.getByText('Will be added')).toBeInTheDocument();
expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => {
const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' }));
expect(screen.getByText('Delete the journal')).toBeInTheDocument();
expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument();
first.unmount();
const unlink: JourneyHeaderCard = {
...JAPAN,
trips: [{ ...TOKYO, change: 'unlink' }],
entries: 1,
suggestions: 4,
};
shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' }));
expect(screen.getByText('Tokyo days')).toHaveClass('line-through');
expect(screen.getByText('Will be taken out')).toBeInTheDocument();
expect(screen.getByText('1 written entry stays')).toBeInTheDocument();
expect(screen.getByText('4 open suggestions go')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => {
const onOpen = vi.fn();
const applied = shown(change('applied', JAPAN), onOpen);
fireEvent.click(screen.getByRole('button', { name: 'Open the journal' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' });
applied.unmount();
shown(change('failed', JAPAN));
expect(screen.queryByText('Tokyo days')).toBeNull();
});
it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => {
expect(headerValueKey('status', 'archived')).toBe('journey.status.archived');
expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto');
expect(headerValueKey('show_mood', '✓')).toBe('settings.on');
expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull();
expect(headerValueKey('status', 'toString')).toBeNull();
const rows: AssistantChangePreview['rows'] = [
{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' },
{ op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' },
{ op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' },
{ op: 'change', labelKey: 'name', before: 'A', after: 'B' },
];
expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']);
});
});
@@ -0,0 +1,148 @@
import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
import { AlignLeft, Luggage, Type } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
import { useCardFormat } from '../parts/useCardFormat';
import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel';
/**
* What a change row of a journal itself adds to the row it already is (its
* verb and the journal's name): on the faint line the name, subtitle and
* settings it changes, each old to new; under the row the journal's trips,
* the one the change links or unlinks marked, and what the change brings or
* takes along. A change that failed, was discarded or ran out shows the row
* alone.
*/
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
const WORD_ICONS = { name: Type, description: AlignLeft } as const;
const NOTE = 'm-0 mt-1.5 text-caption';
const TRIP_TONE: Readonly<Record<JourneyHeaderTrip['change'], PillTone>> = {
kept: 'neutral',
link: 'success',
unlink: 'danger',
};
/** One setting a change sets, by its name, old to new. */
function SettingChange({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const look = headerFieldOf(row.field);
if (!look) return null;
const named = (value: string | null) => {
const key = headerValueKey(row.field, value);
if (key) return t(key);
return value || null;
};
const before = row.op === 'add' ? null : named(row.before);
const after = named(row.after) ?? t('assistant.card.common.removed');
const name = t(look.labelKey);
return (
<FactChange
icon={look.Icon}
before={before === null ? null : t('assistant.card.journeyHeader.setting', { name, value: before })}
after={t('assistant.card.journeyHeader.setting', { name, value: after })}
tone="info"
/>
);
}
/** The journal's new name and subtitle and the settings a change sets. */
export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) {
const { t } = useTranslation();
const rows = item.preview?.rows ?? [];
const words = rows.filter(
(row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } =>
row.op === 'change' && row.labelKey in WORD_ICONS
);
return (
<>
{words.map((row) => (
<FactChange
key={row.labelKey}
icon={WORD_ICONS[row.labelKey]}
before={row.before || null}
after={row.after || t('assistant.card.common.removed')}
tone="info"
/>
))}
{settingRows(rows).map((row) => (
<SettingChange key={row.field} row={row} />
))}
</>
);
}
function TripChip({ trip }: { trip: JourneyHeaderTrip }) {
const { t } = useTranslation();
const format = useCardFormat();
const days = trip.startDate ? format.date(trip.startDate) : null;
return (
<SoftPill
tone={TRIP_TONE[trip.change]}
className="max-w-full"
icon={<Luggage size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
>
{trip.change === 'link' && <span className="sr-only">{t('assistant.card.journeyHeader.tripLink')}</span>}
{trip.change === 'unlink' && <span className="sr-only">{t('assistant.card.journeyHeader.tripUnlink')}</span>}
<bdi className={`truncate ${trip.change === 'unlink' ? 'line-through' : ''}`}>{trip.title}</bdi>
{days && <span className="text-content-faint">{days}</span>}
</SoftPill>
);
}
/** What the change brings along or takes with it, in the journal's words. */
function HeaderNotes({ card }: { card: JourneyHeaderCard }) {
const { t } = useTranslation();
const unlinking = card.trips.some((trip) => trip.change === 'unlink');
if (card.change === 'willDelete') {
return <p className={`${NOTE} text-danger`}>{t('assistant.card.journeyHeader.deletes')}</p>;
}
if (unlinking) {
return (
<>
{card.entries !== null && card.entries > 0 && (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.entriesStay', { count: card.entries })}
</p>
)}
{card.suggestions !== null && card.suggestions > 0 && (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })}
</p>
)}
</>
);
}
if (card.suggestions === null || card.suggestions === 0) return null;
return (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })}
</p>
);
}
/** The journal's trips and what the change brings or takes, under the row. */
export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) {
if (GONE.has(item.state)) return null;
return (
<div>
{card.subtitle && card.change === 'new' && (
<p className="m-0 text-caption text-content-muted">
<bdi>{card.subtitle}</bdi>
</p>
)}
{card.trips.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-1">
{card.trips.map((trip, index) => (
<TripChip key={`${trip.id ?? 'x'}-${index}`} trip={trip} />
))}
</div>
)}
<HeaderNotes card={card} />
</div>
);
}
@@ -0,0 +1,105 @@
/**
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
*
* A change of a journal's photos in the chat: the entries the photos leave
* and reach, the photos as thumbnails, a caption old to new, what stays
* where, and the receipt that opens the entry.
*/
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
const MOVE: JourneyPhotosCard = {
kind: 'journeyPhotos',
journeyId: 3,
mode: 'move',
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
photos: [
{ photoId: 101, video: false, caption: 'Black sand' },
{ photoId: 102, video: true, caption: null },
],
more: 4,
kept: false,
caption: null,
};
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
const preview: AssistantChangePreview = {
toolCallId: 'p1',
verbKey,
subject: 'Reynisfjara',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
domain: 'journey',
card,
};
const receipt: AssistantApplied = {
toolCallId: 'p1',
entity: 'journey_entry',
ids: [2],
dayId: null,
undoUntil: null,
};
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
}
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
return render(
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('a change of a journal photos', () => {
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
expect(screen.getByText('Vik')).toBeInTheDocument();
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
expect(screen.getByText('4 more')).toBeInTheDocument();
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
expect(screen.getByText('Entry cover')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
const caption = {
...MOVE,
mode: 'caption' as const,
from: null,
to: null,
more: 0,
photos: [{ photoId: 101, video: false, caption: 'Old' }],
};
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
expect(screen.getByText('Old')).toHaveClass('line-through');
expect(screen.getByText('New')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
expect(screen.getByText('Removed')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
const onOpen = vi.fn();
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
});
});
@@ -0,0 +1,107 @@
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel } from '../../../Journey/journeyCard';
import { SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
/**
* What a change row of a journal's photos adds under the row: the entries
* the photos leave and reach, the photos themselves as small thumbnails (the
* first marked as the entry's cover in a new order), a caption old to new,
* and what the change leaves where. A change that failed, was discarded or
* ran out shows the row alone.
*/
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
const { locale } = useTranslation();
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
}
/** The entries the photos leave and reach, the way between them marked. */
function Route({ card }: { card: JourneyPhotosCard }) {
if (!card.from && !card.to) return null;
return (
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
{card.from && <EntryName entry={card.from} />}
{card.from && card.to && (
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
)}
{card.to && <EntryName entry={card.to} />}
</p>
);
}
function Thumbs({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.photos.length === 0 && card.more === 0) return null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{card.photos.map((photo, index) => (
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
<img
src={`/api/photos/${photo.photoId}/thumbnail`}
alt={photo.caption ?? ''}
loading="lazy"
className="size-full rounded-lg bg-surface-secondary object-cover"
/>
{photo.video && (
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
<span className="sr-only">{t('assistant.card.journey.video')}</span>
</SoftPill>
)}
{card.mode === 'order' && index === 0 && (
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
)}
</span>
))}
{card.more > 0 && (
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.common.more', { count: card.more })}
</SoftPill>
)}
</div>
);
}
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
function Notes({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
if (card.mode === 'unlink' || card.mode === 'move') {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
}
if (card.mode === 'order' && card.photos.length > 0) {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
}
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
return null;
}
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
const { t } = useTranslation();
if (GONE.has(item.state)) return null;
return (
<div>
<Route card={card} />
<Thumbs card={card} />
{card.caption && (
<span className="mt-1.5 flex">
<FactChange
icon={MessageSquareText}
before={card.caption.before}
after={card.caption.after ?? t('assistant.card.common.removed')}
tone="info"
/>
</span>
)}
<Notes card={card} />
</div>
);
}
@@ -0,0 +1,82 @@
import type { AssistantChangeRow } from '@trek/shared';
import {
CircleDot,
CloudSun,
Eye,
EyeOff,
Footprints,
Luggage,
MapPin,
Palette,
Route,
Smile,
Sparkles,
ThumbsUp,
type LucideIcon,
} from 'lucide-react';
/**
* The pure side of a change of a journal itself: the glyph and name of each
* setting a change row names (under the tool's own field names), and how its
* value reads, a status by its name, a switch as on or off, a cleared status
* override as following the trip dates. The names are the journal's own,
* as its settings dialog writes them.
*/
interface FieldLook {
Icon: LucideIcon;
labelKey: string;
}
const FIELDS: Readonly<Record<string, FieldLook>> = {
status: { Icon: CircleDot, labelKey: 'journey.settings.status' },
status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' },
show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' },
show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' },
show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' },
photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' },
show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' },
auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' },
cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' },
trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' },
suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' },
hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' },
};
const STATUSES: Readonly<Record<string, string>> = {
draft: 'journey.status.draft',
active: 'journey.status.active',
completed: 'journey.status.completed',
archived: 'journey.status.archived',
live: 'journey.frontpage.live',
};
const FLAG_ON = '✓';
const FLAG_OFF = '✗';
function own<T>(table: Readonly<Record<string, T>>, key: string | null | undefined): T | null {
if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null;
return table[key] ?? null;
}
/** The look of a setting a row names; null for a field the card has no name for. */
export function headerFieldOf(field: string | undefined): FieldLook | null {
return own(FIELDS, field);
}
/**
* The key a value of a setting reads by, or null for one that reads as it is
* (a name, a count). A cleared status override follows the trip dates again.
*/
export function headerValueKey(field: string | undefined, value: string | null): string | null {
if (value === FLAG_ON) return 'settings.on';
if (value === FLAG_OFF) return 'settings.off';
if (field === 'status_override' && !value) return 'journey.settings.statusAuto';
if (field === 'status' || field === 'status_override') return own(STATUSES, value);
return null;
}
/** The setting rows of a change, the trips aside: the card draws those as its own chips. */
export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] {
return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null);
}
@@ -1,4 +1,22 @@
import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react';
import {
ArrowDownUp,
ArrowRightLeft,
BookCheck,
BookOpen,
BookOpenText,
BookPlus,
BookX,
Eye,
ImageMinus,
ImagePlus,
Images,
Link,
MessageSquareText,
RotateCcw,
Settings2,
Type,
Unlink,
} from 'lucide-react';
import type { PackLook } from '../cardTypes';
import { journalTarget } from './journeyCardModel';
@@ -17,6 +35,34 @@ export const JOURNEY_LOOK = {
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
reorderJournalEntries: {
Icon: ArrowDownUp,
tone: 'info',
labelKey: 'assistant.change.verb.reorderJournalEntries',
},
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' },
deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' },
linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' },
unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' },
restoreJournalSuggestions: {
Icon: RotateCcw,
tone: 'info',
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
},
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
captionJournalPhoto: {
Icon: MessageSquareText,
tone: 'info',
labelKey: 'assistant.change.verb.captionJournalPhoto',
},
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
},
tools: {
create_journey: { labelKey: 'assistant.tool.create_journey' },
@@ -35,10 +81,21 @@ export const JOURNEY_LOOK = {
journey_entry: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journey.open',
open: (applied, card) =>
card?.kind === 'journeyEntries'
open: (applied, card) => {
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
return card?.kind === 'journeyEntries'
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
: null,
: null;
},
},
journey: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journeyHeader.open',
open: (applied, card) => {
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
const journeyId = applied.ids[0] ?? fromCard;
return journeyId ? journalTarget(journeyId, null, false) : null;
},
},
},
cards: {
@@ -59,5 +116,11 @@ export const JOURNEY_LOOK = {
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
},
},
journeyHeader: {
entity: () => 'journey',
},
journeyPhotos: {
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
},
},
} satisfies PackLook<'journeyEntries'>;
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
@@ -1,12 +1,15 @@
import type { CardView } from '../cardTypes';
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
import JourneyEntriesBlock from './JourneyEntriesBlock';
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
import { JourneyPhotosBody } from './JourneyPhotosSlots';
/**
* How the kinds of the journeys draw in the chat: the entries a read found
* as a block of their own, and a change row of an entry with its photo beside
* the subject, its day, time and place and whatever an update changes on the
* faint line, and the start of its text under it. This file only maps each
* faint line, and the start of its text under it; a change of the journal
* itself with its settings on the faint line and its trips under it. This file only maps each
* kind to its view: the components live in sibling files, since a file that
* exports anything besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
@@ -18,4 +21,11 @@ export const JOURNEY_VIEWS = {
Facts: JourneyEntryFacts,
Body: JourneyEntryBody,
},
} satisfies { [K in 'journeyEntries']: CardView<K> };
journeyHeader: {
Facts: JourneyHeaderFacts,
Body: JourneyHeaderBody,
},
journeyPhotos: {
Body: JourneyPhotosBody,
},
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
@@ -1,18 +1,19 @@
/**
* FE-AI-HOME-001 to FE-AI-HOME-004
* FE-AI-HOME-001 to FE-AI-HOME-006
*
* The home chat's shared hooks: its key and name, the way into one trip's own
* chat with the question waiting there (a question already typed there
* stays), the way in from the pages outside a trip, and the questions the
* empty home chat offers.
* empty home chat offers, and the journal on screen as a chat.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useJourneyStore } from '../../store/journeyStore';
import { HOME_SUGGESTIONS } from './assistantModel';
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
import { chatPagePath, useAssistantHome, useAssistantHomeLauncher, useRouteJournalChat } from './useAssistantHome';
import { useAssistantSuggestions } from './useAssistantSuggestions';
const INITIAL = useAssistantStore.getState();
@@ -100,3 +101,29 @@ describe('the empty home chat', () => {
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
});
});
function RouteJournal() {
const chat = useRouteJournalChat();
return <p>{chat ? `journal ${chat.id} ${chat.title}` : 'no journal'}</p>;
}
describe('the journal on screen', () => {
it("FE-AI-HOME-005: a journey's page is its journal's chat, under the journey's key, but not in its Studio", () => {
useJourneyStore.setState({ current: { id: 12, title: 'Japan 2026' } as never });
render(<RouteJournal />, { initialEntries: ['/journey/12'] });
expect(screen.getByText('journal -12 Japan 2026')).toBeInTheDocument();
render(<RouteJournal />, { initialEntries: ['/journey/12/studio'] });
expect(screen.getAllByText('no journal')).toHaveLength(1);
render(<RouteJournal />, { initialEntries: ['/journey'] });
expect(screen.getAllByText('no journal')).toHaveLength(2);
// Another journey loaded: the key is the route's, the title waits for its own.
render(<RouteJournal />, { initialEntries: ['/journey/13'] });
expect(screen.getByText(/^journal -13\s*$/)).toBeInTheDocument();
useJourneyStore.setState({ current: null });
});
it('FE-AI-HOME-006: a chat switched to from home opens on its own page', () => {
expect(chatPagePath({ id: 5, title: 'Kyoto' })).toBe('/trips/5');
expect(chatPagePath({ id: -12, title: 'Japan 2026' })).toBe('/journey/12');
});
});
@@ -1,8 +1,10 @@
import { useCallback, useMemo } from 'react';
import { useMatch } from 'react-router';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { assistantChatKey, assistantScopeOf, HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import { useJourneyStore } from '../../store/journeyStore';
import type { AssistantChatTrip } from './assistantViewContext';
/**
@@ -42,3 +44,27 @@ export function useAssistantHomeLauncher() {
const open = useAssistantStore((s) => s.open);
return { available, open };
}
/**
* The journal on screen as a chat: on a journey's own page the journey's
* conversation (the one TREK Studio holds too), under its key and with the
* journey's title once the page has loaded it; null elsewhere and in the
* Studio, whose column holds that chat itself.
*/
export function useRouteJournalChat(): AssistantChatTrip | null {
const match = useMatch('/journey/:id/*');
const inStudio = useMatch('/journey/:id/studio/*') !== null;
const id = Number(match?.params.id);
const journeyId = !inStudio && Number.isInteger(id) && id > 0 ? id : null;
const title = useJourneyStore((s) => (s.current && s.current.id === journeyId ? s.current.title : ''));
return useMemo(
() => (journeyId === null ? null : { id: assistantChatKey({ kind: 'journey', journeyId }), title }),
[journeyId, title]
);
}
/** The page a chat switched to from the home chat lives on: a journey's for its key, a trip's otherwise. */
export function chatPagePath(chat: AssistantChatTrip): string {
const scope = assistantScopeOf(chat.id);
return scope.kind === 'journey' ? `/journey/${scope.journeyId}` : `/trips/${chat.id}`;
}
@@ -0,0 +1,77 @@
/**
* FE-AI-STATUS-001 to FE-AI-STATUS-003
*
* The views of the assistant ask for its status through this hook: not while
* both addons that open /api/ai are off, where the server answers 404, and
* as soon as one comes on, which the addon list can say after the view
* mounted (the first load, an admin's switch). The assistant's own addon
* switching asks again while AI parsing keeps /api/ai open.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAssistantStore } from '../../store/assistantStore';
import { useAssistantStatus } from './useAssistantStatus';
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
/** The addon list as it lands from the server, with these addons on. */
const addonsOn = (...ids: string[]) =>
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
});
describe('useAssistantStatus', () => {
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
renderHook(() => useAssistantStatus());
act(() => addonsOn('mcp'));
expect(status).not.toHaveBeenCalled();
expect(useAssistantStore.getState().status).toBeNull();
act(() => addonsOn('ai_assistant'));
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
// The list read again with the assistant still on asks nothing more.
act(() => addonsOn('ai_assistant', 'mcp'));
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
act(() => addonsOn());
expect(useAssistantStore.getState().status).toBeNull();
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
act(() => addonsOn('llm_parsing', 'ai_assistant'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
act(() => addonsOn('llm_parsing'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
});
});

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