Compare commits

...

167 Commits

Author SHA1 Message Date
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
1017 changed files with 27451 additions and 4396 deletions
+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:
+145 -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,10 @@ on:
description: "Type MAJOR (all caps) to confirm a major release"
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 +52,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 +69,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
@@ -77,14 +96,13 @@ 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
- 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 +161,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 +272,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 +284,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 +327,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 +349,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 +375,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 +415,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
+142
View File
@@ -0,0 +1,142 @@
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
printf '%s\n' '{"extends": "./tsconfig.strict.json", "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
+12 -1
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
+53 -25
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/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
</div>
---
@@ -214,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/en?mtm_campaign=trek">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
<img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="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/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
</div>
<br />
@@ -234,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/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
<div align="center">
@@ -395,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
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.4
version: 5.0.1
description: Minimal Helm chart for TREK app
appVersion: "4.3.4"
appVersion: "5.0.1"
+12
View File
@@ -112,6 +112,18 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | 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 }}
+6
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:
+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 }}
+20 -2
View File
@@ -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,20 @@ 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_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 +179,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 +197,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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.4",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+8 -8
View File
@@ -1,11 +1,11 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 221,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"trek/no-swallowed-catch": 129,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
@@ -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": 1927,
"@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,
+10 -1
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"],
+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",
+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,
+4 -5
View File
@@ -2,14 +2,13 @@
"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/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/PDF/TripPDF.tsx": 1082,
"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,
+1 -2
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,
@@ -105,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,
+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.
+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>
)
}
@@ -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,206 @@
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
import { MapPin, RefreshCw, Route, Search, 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 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<HTMLElement>(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 (
<section
ref={panel}
aria-label={t('admin.trekApi.quota.title')}
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
{status?.keySource === 'env' && (
<FigureBadge
label={t('admin.trekApi.quota.key')}
value="TREK_SERVICES_KEY"
info={t('admin.trekApi.quota.envKey')}
/>
)}
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
</div>
</div>
{status && status.services.length > 0 && (
<ul
aria-label={t('admin.trekApi.quota.title')}
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
>
{status.services.map((allowance) => (
<ServiceTile key={allowance.service} allowance={allowance} />
))}
</ul>
)}
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
{canRegenerate && (
<button
type="button"
onClick={() => setConfirming(true)}
disabled={regenerating}
className="inline-flex items-center gap-1.5 self-start rounded-lg text-caption font-medium text-content-secondary hover:text-content disabled:opacity-50"
>
<RefreshCw
size={13}
strokeWidth={2}
className={regenerating ? 'motion-safe:animate-spin' : ''}
aria-hidden="true"
/>
{t('admin.trekApi.quota.regenerate')}
</button>
)}
<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}
/>
</section>
);
}
@@ -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,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') },
@@ -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 };
}
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantStatus } from './useAssistantStatus';
import { useAssistantThread } from './useAssistantThread';
/**
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
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.
@@ -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' },
@@ -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));
});
});
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAddonStore } from '../../store/addonStore';
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
/**
* Asks for the assistant's status when a view of it mounts, and again when
* either addon that opens /api/ai is switched: the addon list can land after
* the view, and an admin can switch either addon while it is open. The
* assistant's own addon counts on its own, since the status says it runs only
* while that one is on, even with AI parsing keeping /api/ai open. The store
* asks the server only while one is on (aiRoutesOpen).
*/
export function useAssistantStatus(): void {
const loadStatus = useAssistantStore((s) => s.loadStatus);
const routesOpen = useAddonStore(aiRoutesOpen);
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
useEffect(() => {
void loadStatus();
}, [routesOpen, assistantOn, loadStatus]);
}
+14 -2
View File
@@ -25,13 +25,25 @@ interface CollabLink {
pinned: boolean | number
}
// The glyph holds the place until a favicon has really arrived: a host that never
// answers, or answers with a blank pixel, left the chip with an empty square.
function LinkIcon({ url }: { url: string }) {
const [failed, setFailed] = useState(false)
const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading')
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
const loaded = state === 'loaded'
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
{!loaded && <Link2 size={13} />}
{favicon && state !== 'failed' && (
<img
src={favicon}
alt=""
hidden={!loaded}
onLoad={(e) => setState(e.currentTarget.naturalWidth > 2 ? 'loaded' : 'failed')}
onError={() => setState('failed')}
/>
)}
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
</span>
)
@@ -38,6 +38,20 @@ function VoterChip({ name, src, offset }: { name: string; src: string | null; of
);
}
/** The voters past the third, as a count on the end of the stack; their names on hover. */
function MoreVoters({ names }: { names: string[] }) {
return (
<Tooltip label={names.map((name) => name || '?').join(', ')}>
<span
className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full border-[1.5px] border-surface-card bg-surface-tertiary px-[3px] font-bold tabular-nums text-content-muted"
style={{ ...fs(7), marginInlineStart: -5 }}
>
+{names.length}
</span>
</Tooltip>
);
}
/** A fact about the poll on its head band, set like the type chip on a booking card. */
export function PollChip({
icon,
@@ -138,6 +152,7 @@ export function PollOptionRow({
{voters.slice(0, 3).map((voter, index) => (
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
))}
{voters.length > 3 && <MoreVoters names={voters.slice(3).map((voter) => voter.name)} />}
</span>
)}
{pending && <span className="sr-only">{pending.label}</span>}
@@ -191,6 +191,18 @@ describe('CollabPolls', () => {
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-025: past three voters the stack ends in a count of the rest', async () => {
const voters = ['anna', 'ben', 'cleo', 'dana', 'emil'].map((username, i) => ({ user_id: 10 + i, username, avatar_url: null }));
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true, options: [{ id: 1, text: 'Paris', label: 'Paris', voters }, { id: 2, text: 'Rome', label: 'Rome', voters: [] }] })] }),
),
);
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('+2')).toBeInTheDocument();
expect(screen.queryByText('D')).not.toBeInTheDocument();
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
@@ -1,4 +1,7 @@
import { render, screen } from '../../../tests/helpers/render'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { buildTrip } from '../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -171,6 +174,20 @@ describe('WhatsNextWidget', () => {
expect(screen.getByText('TBD')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
})))
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
})
render(<WhatsNextWidget />)
expect(await screen.findByText('15:00')).toBeInTheDocument()
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -8,6 +8,8 @@ import { fs } from '../shared/DialogShell'
import CollabPanelHead from './CollabPanelHead'
import EmptyState from '../shared/EmptyState'
import { localToday } from '../Planner/today'
import { useTripStays } from '../Planner/useTripStays'
import { stayStartingOn } from '../../utils/dayMerge'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
@@ -48,6 +50,10 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const { days, assignments } = useTripStore()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
// A hotel on the day its stay begins is timed by the check-in when the stop has no time of its own.
const tripId = useTripStore(s => s.trip?.id ?? 0)
const { tripAccommodations, loadAccommodations } = useTripStays(tripId)
useEffect(() => { loadAccommodations() }, [loadAccommodations])
const upcoming = useMemo(() => {
const now = new Date()
@@ -60,14 +66,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const dayAssignments = assignments[String(day.id)] || []
for (const a of dayAssignments) {
if (!a.place) continue
const time = a.place.place_time || stayStartingOn(tripAccommodations, a.place.id, day.id)?.check_in || null
// Include: today (not started yet, or still running) + all future days
const isFutureDay = day.date > nowDate
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
const isTodayFuture = day.date === nowDate && (!time || time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
if (isFutureDay || isTodayFuture) {
items.push({
id: a.id,
name: a.place.name,
time: a.place.place_time,
time,
endTime: a.place.end_time,
date: day.date,
dayTitle: day.title,
@@ -88,7 +95,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
})
return items.slice(0, 8)
}, [days, assignments, tripMembers])
}, [days, assignments, tripMembers, tripAccommodations])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -0,0 +1,260 @@
/**
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
*
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
* two sizes: the renderer opened on a camera framed for the browser window, while this map
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
* cafes opened on northern France with one pin in sight.
*/
import type { CollectionPlace } from '@trek/shared';
import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({
position: null,
mode: 'off',
error: null,
errorCode: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
}),
}));
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}));
// Every map this file builds, newest last.
const maps: L.Map[] = [];
L.Map.addInitHook(function (this: L.Map) {
maps.push(this);
});
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
const PIN = 22;
/** The desktop panel's control bar ends 52 px below the map's top edge. */
const BAR = 52;
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
const CONTROLS = 54;
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
const box = { width: 567, height: 724 };
const sized = ['clientWidth', 'clientHeight'] as const;
const originals = new Map<string, PropertyDescriptor | undefined>();
const startWindow = { width: window.innerWidth, height: window.innerHeight };
function setWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
}
beforeAll(() => {
for (const key of sized) {
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
Object.defineProperty(HTMLElement.prototype, key, {
configurable: true,
get: () => (key === 'clientWidth' ? box.width : box.height),
});
}
});
afterAll(() => {
for (const key of sized) {
const original = originals.get(key);
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
});
beforeEach(() => {
// The desktop split beside the list, unless a test says otherwise.
setWindow(1600, 1000);
Object.assign(box, { width: 567, height: 724 });
});
afterEach(() => {
setWindow(startWindow.width, startWindow.height);
resetAllStores();
});
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
}
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
const CAFES = [
place(1, 'Cafe Central', 48.2105, 16.366),
place(2, 'Cafe Sperl', 48.1999, 16.3634),
place(3, 'Caffe San Marco', 45.6513, 13.777),
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
place(5, 'Cafe de Flore', 48.8542, 2.3331),
place(6, 'A Brasileira', 38.7102, -9.1417),
place(7, 'Cafe Majestic', 41.1472, -8.6064),
place(8, 'The Coffee Collective', 55.696, 12.5509),
];
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
const VIEWPOINTS = [
place(11, 'Seceda', 46.6046, 11.722),
place(12, 'Preikestolen', 58.9863, 6.1904),
place(13, 'Trolltunga', 60.1241, 6.74),
place(14, 'Rigi Kulm', 47.056, 8.485),
place(15, 'Zugspitze', 47.4211, 10.9852),
place(16, 'Eibsee loop', 47.4575, 10.9918),
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
place(18, 'Monterosso al Mare', 44.1456, 9.654),
];
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
const TEMPLES = [
place(31, 'Fushimi Inari', 34.9671, 135.7727),
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
place(33, 'Senso-ji', 35.7148, 139.7967),
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
place(35, 'Itsukushima', 34.2959, 132.3199),
place(36, 'Himeji Castle', 34.8394, 134.6939),
place(37, 'Shirakawa-go', 36.2578, 136.9066),
place(38, 'Todai-ji', 34.689, 135.8398),
];
// "All saved" across the globe, from Patagonia to Taiwan.
const WORLD = [
place(21, 'Torres del Paine', -50.942, -73.4068),
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
place(23, 'Landmannalaugar', 63.9926, -19.0607),
place(24, 'Taroko Gorge', 24.1594, 121.6211),
place(25, 'Lycian Way', 36.5497, 29.1153),
...VIEWPOINTS.slice(0, 3),
];
function mapOf(places: CollectionPlace[], underBar: boolean) {
return (
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
);
}
async function renderMap(places: CollectionPlace[], underBar = true) {
const view = render(mapOf(places, underBar));
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
return { view, map: maps[maps.length - 1] };
}
/** Where each place sits on the map, in pixels from its top left corner. */
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
}
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
for (const { x, y } of pointsOf(map, places)) {
expect(x).toBeGreaterThanOrEqual(PIN);
expect(x).toBeLessThanOrEqual(box.width - PIN);
expect(y).toBeGreaterThanOrEqual(top + PIN);
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
}
}
/** Whether an element around the map carries the class. */
function wrappedIn(map: L.Map, className: string): boolean {
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
if (el.classList.contains(className)) return true;
}
return false;
}
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
const points = pointsOf(map, places);
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
const freeX = box.width - 2 * PIN;
const freeY = box.height - top - CONTROLS - 2 * PIN;
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
}
describe('CollectionMap framing', () => {
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
const { map } = await renderMap(CAFES);
expectEveryPinClear(map, CAFES, BAR);
expectFramedClosely(map, CAFES, BAR);
});
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
const { view, map } = await renderMap(CAFES);
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
view.rerender(mapOf(TEMPLES, true));
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
expectFramedClosely(map, TEMPLES, BAR);
});
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
const { view, map } = await renderMap(CAFES);
act(() => {
map.setView([50, 10], 7, { animate: false });
});
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
view.rerender(mapOf(reloaded, true));
expect(map.getZoom()).toBe(7);
expect(map.getCenter().lat).toBeCloseTo(50, 5);
expect(map.getCenter().lng).toBeCloseTo(10, 5);
});
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
const { view, map } = await renderMap(CAFES);
const zoom = map.getZoom();
const center = map.getCenter();
view.rerender(mapOf([], true));
expect(map.getZoom()).toBe(zoom);
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
});
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
const { map } = await renderMap([CAFES[0]]);
expectEveryPinClear(map, [CAFES[0]], BAR);
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getZoom()).toBeLessThanOrEqual(13);
});
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
const { map } = await renderMap(WORLD);
expectEveryPinClear(map, WORLD, BAR);
});
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
setWindow(390, 844);
Object.assign(box, { width: 356, height: 414 });
const { map } = await renderMap(CAFES, false);
expectEveryPinClear(map, CAFES, 0);
expectFramedClosely(map, CAFES, 0);
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
// and the buttons sit in the bottom 54 px the frame keeps free.
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
});
});
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport'
interface CollectionMapProps {
places: CollectionPlace[]
@@ -12,20 +13,75 @@ interface CollectionMapProps {
/** Clicking the map background clears the selection. */
onDeselect?: () => void
dark: boolean
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
underBar?: boolean
}
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
const PIN_REACH = 26
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
const EDGE = PIN_REACH + 14
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
const TOP_BAR = 52
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
const BOTTOM_CONTROLS = 54
/**
* Half the side of the square framed around a lone place, in degrees: about 11 km
* from north to south, so a list of one opens on its town rather than on the street
* the closest fit would zoom to.
*/
const LONE_PLACE_SPAN = 0.05
/** What the frame keeps clear of pins, in map pixels per edge. */
function framePadding(underBar: boolean): ViewportPadding {
const top = (underBar ? TOP_BAR : 0) + EDGE
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
}
/**
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* The coordinates the map frames. A lone place, or several on one spot, has no extent
* to fit, so a small square around it stands in.
*/
function framePoints(places: CollectionPlace[]): [number, number][] {
const points = places.flatMap(p => (
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
))
if (points.length === 0) return points
const [lat, lng] = points[0]
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
return [
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
]
}
/**
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* clustering). One of the three list views; clicking a marker selects the place.
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
* just fills it.
*
* The map frames every place itself, through the renderers' focus props. Their
* opening camera is framed for the browser window, and this map is a box a fraction
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
* middle of the list with most places outside; and the count it used to key the fit
* on never changed between two lists of eight. The frame is fitted in the map's own
* box instead, clear of the edges and of the controls floating over it, and lands
* while the map is built rather than flying in.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a
// reload, another sort order or an edited note leaves a map the user moved alone.
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
return (
<div style={{ width: '100%', height: '100%' }}>
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
// where the frame's bottom padding keeps the pins out from under them.
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
// question a small picture cannot answer.
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
// No center/zoom: the map frames itself on the collection's places at mount, and
// falls back to the world view for a collection with none.
tileUrl={tileUrl}
fitKey={pts.length}
focusPoints={focusPoints}
focusKey={focusKey}
fitPadding={framePadding(underBar)}
/>
</div>
)
@@ -0,0 +1,51 @@
/**
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
*
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
*/
import { describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import CollectionMapPanel from './CollectionMapPanel';
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
vi.mock('./CollectionMap', () => ({
default: (props: { underBar?: boolean }) => {
handedToMap.underBar = props.underBar;
return null;
},
}));
function panel(overlay: boolean) {
return (
<CollectionMapPanel
places={[]}
selectedPlaceId={null}
onSelect={vi.fn()}
onDeselect={vi.fn()}
dark={false}
overlay={overlay}
view="list"
onToggleView={vi.fn()}
search=""
onSearch={vi.fn()}
t={(key) => key}
/>
);
}
describe('CollectionMapPanel', () => {
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
const { container } = render(panel(true));
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
expect(handedToMap.underBar).toBe(true);
});
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
const { container } = render(panel(false));
expect(container.querySelector('.col-map-topbar')).toBeNull();
expect(handedToMap.underBar).toBe(false);
});
});
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
onOpenPlace={onSelect}
onDeselect={onDeselect}
dark={dark}
underBar={overlay}
/>
{overlay && (
<div className="col-map-topbar">
@@ -1,4 +1,4 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
@@ -66,6 +66,7 @@ function renderCard(
canManage?: boolean
providerName?: string
onReconnect?: () => void
onEditConnection?: () => void
} = {},
) {
const utils = render(
@@ -75,6 +76,7 @@ function renderCard(
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
onEditConnection={opts.onEditConnection}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
@@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => {
expect(updateLink).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: the connection behind it', () => {
const S = en as unknown as Record<string, string>
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
const onEditConnection = vi.fn()
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
expect(editButton()).not.toBeInTheDocument()
openSettings()
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(editButton()).not.toBeInTheDocument()
member.unmount()
renderCard()
openSettings()
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,13 +1,13 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
} from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
@@ -27,6 +27,7 @@ export default function DocSyncBinding({
sync,
canManage,
onReconnect,
onEditConnection,
}: {
link: DocSyncLink
providerName: string
@@ -38,6 +39,8 @@ export default function DocSyncBinding({
* not carry; left out, a refused credential is reported but not curable.
*/
onReconnect?: () => void
/** The same form at any time, to change the address or the login, or to remove the connection. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -237,6 +240,21 @@ export default function DocSyncBinding({
</div>
</div>
)}
{onEditConnection && (
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<span className="flex justify-end">
<button
type="button"
onClick={onEditConnection}
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
</span>
</Row>
)}
</div>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
@@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
@@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
},
}
})
@@ -81,11 +83,13 @@ function Harness({
provider,
onClose,
onConnected,
onRemoved,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
onRemoved?: () => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
@@ -94,7 +98,9 @@ function Harness({
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
return (
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
)
}
/** Let the hook's initial load land before anything is asserted. */
@@ -492,3 +498,52 @@ describe('DocSyncConnectModal with a connection already stored', () => {
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
describe('DocSyncConnectModal removal', () => {
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
// The confirmation is portalled after the dialog, so its button is the last one of that name.
const confirmRemoval = () => {
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
return named[named.length - 1]
}
async function openWithRemoval(stored: boolean) {
if (stored) listConnections.mockResolvedValue([connection()])
const onRemoved = vi.fn()
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
await flush()
return onRemoved
}
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
expect(deleteConnection).not.toHaveBeenCalled()
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
})
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
expect(onRemoved).not.toHaveBeenCalled()
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
await openWithRemoval(false)
expect(removeButton()).not.toBeInTheDocument()
})
})
@@ -1,12 +1,13 @@
import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/**
@@ -22,11 +23,14 @@ export default function DocSyncConnectModal({
sync,
onClose,
onConnected,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
/** After the stored connection was removed from here; left out, the form offers no removal. */
onRemoved?: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
@@ -34,6 +38,11 @@ export default function DocSyncConnectModal({
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const remove = async () => {
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
}
const test = async () => setVerdict(await form.probe())
const labelId = useId()
@@ -46,6 +55,7 @@ export default function DocSyncConnectModal({
<DialogShell
onClose={onClose}
labelledBy={labelId}
blocked={confirmRemove}
header={(
<DialogHeader
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
)}
footer={(
<DialogFooter>
{existing && onRemoved && (
<DeleteButton
label={t('docsync.connection.remove')}
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
/>
)}
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<FooterSpacer />
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
</span>
</label>
</div>
<ConfirmDialog
isOpen={confirmRemove}
onClose={() => setConfirmRemove(false)}
onConfirm={() => void remove()}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
/>
</DialogShell>
)
}
@@ -1,6 +1,6 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import TrekIcon from '../../shared/TrekIcon'
import { Badge } from './DocSyncBits'
import { toggledDirection, type SyncDirection } from './docSyncModel'
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react'
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import { StateBadge } from './DocSyncBits'
import { slugFor } from './docSyncModel'
import { useConflicts } from './useConflicts'
@@ -62,6 +62,9 @@ export default function DocSyncPanel({
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links])
// The connection behind the folder picker, which the picker can send back to.
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
const labelId = useId()
@@ -113,6 +116,7 @@ export default function DocSyncPanel({
sync={sync}
canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
/>
{/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not
@@ -141,6 +145,7 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
onRemoved={() => setConnecting(null)}
/>
)}
@@ -149,6 +154,7 @@ export default function DocSyncPanel({
provider={reconnecting}
sync={sync}
onClose={() => setReconnecting(null)}
onRemoved={() => setReconnecting(null)}
onConnected={id => {
setReconnecting(null)
// Run straight away, as binding does: the card still reports the
@@ -168,6 +174,7 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
/>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
/**
* The folder picker of the document-sync dialog.
@@ -84,9 +84,10 @@ function deferred<T>() {
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
suggestedName?: string
providerName?: string
onEditConnection?: () => void
}) {
const sync = useDocSync(TRIP_ID, false)
return (
@@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }
sync={sync}
onClose={onClose}
onBound={onBound}
onEditConnection={onEditConnection}
/>
)
}
@@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => {
expect(createButton()).toBeEnabled()
})
})
describe('DocSyncScopeModal connection', () => {
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
const onEditConnection = vi.fn()
const first = render(<Harness onEditConnection={onEditConnection} />)
await waitFor(() => expect(listScopes).toHaveBeenCalled())
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
expect(createLink).not.toHaveBeenCalled()
first.unmount()
render(<Harness />)
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, useDocSync } from './useDocSync'
@@ -22,6 +22,7 @@ export default function DocSyncScopeModal({
sync,
onClose,
onBound,
onEditConnection,
}: {
connection: DocSyncConnection
providerName: string
@@ -30,6 +31,8 @@ export default function DocSyncScopeModal({
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const labelId = useId()
@@ -63,7 +66,19 @@ export default function DocSyncScopeModal({
)}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="flex items-start justify-between gap-3">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
{onEditConnection && (
<button
type="button"
onClick={onEditConnection}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
)}
</div>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
/**
* The document-sync hook.
@@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
@@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
},
}
})
@@ -126,6 +128,7 @@ beforeEach(() => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true })
deleteConnectionApi.mockResolvedValue({ success: true })
})
describe('useDocSync initial load', () => {
@@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => {
})
})
describe('useDocSync removeConnection', () => {
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
// The server drops the bindings with the connection (ON DELETE CASCADE).
listConnectionsApi.mockResolvedValue([])
listLinksApi.mockResolvedValue([])
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(true)
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
expect(result.current.connectionFor('nextcloud')).toBeNull()
expect(result.current.links).toEqual([])
expect(result.current.busy).toBeNull()
})
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(false)
expect(result.current.error).toBe('forbidden')
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
expect(result.current.busy).toBeNull()
})
})
describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
@@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
}
}, [tripId, load])
/**
* Forget a stored connection, and with it every binding of this trip that
* runs through it. The documents stay on both sides; what goes is the
* address and the login TREK kept for the store, which is the one thing a
* person changing accounts or folders had no way to take back.
*/
const removeConnection = useCallback(async (connectionId: number) => {
setBusy('connection')
setError(null)
try {
await docsyncApi.deleteConnection(tripId, connectionId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo
@@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
saveConnection, testConnection, removeConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
+13 -9
View File
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
{/* The close button sits in a row of its own above the scrolling content rather
than floating over it, so no card ever runs up against it. */}
<div className="flex shrink-0 justify-end px-4 pt-4">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
}
})
})
describe('DatePicker inside a clipping dialog (#2858)', () => {
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
const user = userEvent.setup()
const { container } = render(
<div style={{ overflow: 'hidden' }}>
<DatePicker value="2026-03-15" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
// A trigger at the right edge, where the end date of a range sits.
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
await user.click(trigger)
const day31 = screen.getByRole('button', { name: '31' })
// Rendered outside the dialog that would clip it.
expect(container.contains(day31)).toBe(false)
const panel = day31.closest('.fixed') as HTMLElement
expect(panel.style.top).toBe('140px')
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
})
})
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
/**
* The header steps up a level on each click, days → months → years, and a
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
/** The calendar's width; `w-[280px]` below. */
const PANEL_WIDTH = 280
const VIEWPORT_PAD = 8
export function DatePicker({ value, onChange, tripDates }: {
value: string
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
const { t, locale } = useTranslation()
const weekStart = useWeekStartDay()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
// their content: the photo picker cut off the last week and, for the end date in
// the right-hand column, the last weekday (#2858). It now floats over the page,
// pinned to the trigger, flipped above it when below is too short and kept inside
// the viewport sideways.
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
<Calendar size={13} className="text-zinc-400" />
</button>
{open && (
{open && anchored && createPortal(
<>
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
<div
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
style={{
top: anchored.top,
bottom: anchored.bottom,
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
maxHeight: anchored.maxHeight,
}}
>
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
</>
)}
</div>
</>
</>,
document.body,
)}
</div>
)
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(screen.getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
await user.click(within(toPicker).getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(ranges).toHaveLength(1))
+268 -10
View File
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -12,6 +12,35 @@ vi.mock('../../api/websocket', () => ({
// Leaflet does not work in jsdom — mock the entire library
vi.mock('leaflet', () => {
const domEvent = {
on: vi.fn((el: HTMLElement, types: string, fn: (e: Event) => void) => {
types.split(' ').forEach(type => el.addEventListener(type, fn));
}),
stopPropagation: vi.fn((e: Event) => e.stopPropagation()),
disableClickPropagation: vi.fn((el: HTMLElement) => {
for (const type of ['mousedown', 'touchstart', 'dblclick', 'contextmenu']) el.addEventListener(type, e => e.stopPropagation());
}),
};
// The openable card (#2885): one per map, put on and taken off by the map itself.
// Its element is built on the first add, inside a stand-in for the map container.
const cardState = { onMap: false, element: null as HTMLElement | null, onAdd: [] as (() => void)[] };
const mockCard = {
state: cardState,
once: vi.fn((_type: string, fn: () => void) => { cardState.onAdd.push(fn); }),
setLatLng: vi.fn().mockReturnThis(),
setContent: vi.fn().mockReturnThis(),
getElement: vi.fn(() => cardState.element),
addTo: vi.fn(() => {
if (!cardState.element) {
cardState.element = document.createElement('div');
cardState.element.innerHTML = '<div class="trek-journey-popup-title">card</div>';
document.createElement('div').appendChild(cardState.element);
}
cardState.onMap = true;
cardState.onAdd.splice(0).forEach(fn => fn());
}),
remove: vi.fn(() => { cardState.onMap = false; }),
};
const mockMarker = {
addTo: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
@@ -38,31 +67,38 @@ vi.mock('leaflet', () => {
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
// The photo and mark layers subscribe to zoom/pan and cluster in world
// pixels at the current zoom (#1614, #2889).
on: vi.fn(),
off: vi.fn(),
project: vi.fn(() => ({ x: 0, y: 0 })),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0 })),
hasLayer: vi.fn((layer: unknown) => (layer === mockCard ? cardState.onMap : true)),
};
return {
default: {
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
// The map builds its own attribution control so it can collapse to the credit
// alone (see the GL twin: the phone lays a carousel over the bottom edge).
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
tooltip: vi.fn(() => mockCard),
DomEvent: domEvent,
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
tooltip: vi.fn(() => mockCard),
DomEvent: domEvent,
};
});
@@ -76,6 +112,9 @@ import type { Mock } from 'vitest';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
import { mercatorWorldPx } from './journeyMapMarks';
import type { JourneyMapStay } from '@trek/shared';
// maplibre-gl-leaflet hangs the vector basemap into Leaflet's tile pane. The mock
// only has to be callable and hand back a layer with the three methods the callers
@@ -178,6 +217,115 @@ describe('JourneyMap', () => {
);
});
describe('the marker card as a way into the entry (#2885)', () => {
interface MockCard {
state: { onMap: boolean; element: HTMLElement | null; onAdd: (() => void)[] };
addTo: Mock;
remove: Mock;
setContent: Mock;
}
// The leaflet mock hands every map the same card, so each case starts it unbuilt and off the map.
function mockedCard(): MockCard {
const card = vi.mocked(L.tooltip)() as unknown as MockCard;
vi.mocked(L.tooltip).mockClear();
return card;
}
beforeEach(() => {
Object.assign(mockedCard().state, { onMap: false, element: null, onAdd: [] });
});
function renderOpenable(onMarkerCardClick = vi.fn(), visibility?: JourneyMapLayerVisibility) {
const ref = React.createRef<JourneyMapHandle>();
const view = render(
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} onMarkerCardClick={onMarkerCardClick} layerVisibility={visibility} />,
);
return { ...view, ref, onMarkerCardClick };
}
function cardElement(): HTMLElement {
const { element } = mockedCard().state;
if (!element) throw new Error('the card was never built');
return element;
}
function cardTitle(): HTMLElement {
return cardElement().firstElementChild as HTMLElement;
}
it('FE-COMP-JOURNEYMAP-059: a tap on the card over the active pin opens that entry and never reaches the map', () => {
const { ref, onMarkerCardClick } = renderOpenable();
act(() => ref.current?.highlightMarker('e2'));
expect(L.tooltip).toHaveBeenCalledWith(expect.objectContaining({ className: 'map-tooltip trek-journey-tooltip is-openable' }));
const container = cardElement().parentElement as HTMLElement;
const mapClick = vi.fn();
const mapPress = vi.fn();
container.addEventListener('click', mapClick);
container.addEventListener('mousedown', mapPress);
cardTitle().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
cardTitle().click();
act(() => ref.current?.highlightMarker('e1'));
cardTitle().click();
expect(onMarkerCardClick.mock.calls).toEqual([['e2'], ['e1']]);
expect(mapClick).not.toHaveBeenCalled();
expect(mapPress).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-060: the pins do not open or close the card, so leaving the pin for the card keeps it', () => {
const { ref } = renderOpenable();
act(() => ref.current?.highlightMarker('e1'));
// A bound tooltip is what Leaflet closes on the pin's mouseout and blur, which
// a tap on the card sends first. The pins bind none and listen for the tap alone.
expect(mockedMarker().bindTooltip).not.toHaveBeenCalled();
expect(vi.mocked(mockedMarker().on).mock.calls.map((call: unknown[]) => call[0])).toEqual(['click', 'click']);
expect(mockedCard().state.onMap).toBe(true);
expect(mockedCard().remove).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-061: the card follows the active pin and leaves with it or with the pins', () => {
mockedMarkerWithRemove();
const card = mockedCard();
const shown = { plan: true, recorded: true, entries: true };
const onMarkerCardClick = vi.fn();
const { ref, rerender } = renderOpenable(onMarkerCardClick, shown);
const map = mockedMap();
act(() => ref.current?.highlightMarker('e1'));
expect(card.setContent).toHaveBeenLastCalledWith(expect.stringContaining('Paris'));
act(() => ref.current?.highlightMarker('e2'));
expect(card.setContent).toHaveBeenLastCalledWith(expect.stringContaining('Berlin'));
expect(card.addTo).toHaveBeenCalledTimes(1);
act(() => ref.current?.highlightMarker(null));
expect(card.state.onMap).toBe(false);
act(() => ref.current?.highlightMarker('e1'));
map.hasLayer.mockImplementation((layer: unknown) => layer === card && card.state.onMap);
try {
rerender(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} onMarkerCardClick={onMarkerCardClick} layerVisibility={{ ...shown, entries: false }} />);
expect(card.state.onMap).toBe(false);
} finally {
map.hasLayer.mockImplementation((layer: unknown) => (layer === card ? card.state.onMap : true));
}
});
it('FE-COMP-JOURNEYMAP-062: without a handler the card stays a hover label on each pin that the drags on the map pass through', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
act(() => ref.current?.highlightMarker('e1'));
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ className: 'map-tooltip trek-journey-tooltip' }),
);
expect(L.tooltip).not.toHaveBeenCalled();
});
});
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
const ref = React.createRef<JourneyMapHandle>();
render(
@@ -201,6 +349,15 @@ describe('JourneyMap', () => {
expect(firstCall.html).toContain('>1<');
});
it('FE-COMP-JOURNEYMAP-058: a stop without a number (a draft) gets an unnumbered pin', () => {
render(
<JourneyMap checkins={[]} entries={[{ ...entriesWithCoords[0], dayLabel: null }]} />
);
const html = String(vi.mocked(L.divIcon).mock.calls[0][0]?.html);
expect(html).toContain('<svg');
expect(html).toMatch(/font-weight="700"><\/text>/);
});
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
const entriesWithMood = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
@@ -782,9 +939,8 @@ describe('JourneyMap', () => {
});
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
// effect can draw while the map still has no center or zoom. Clustering
// projects to container pixels, which throws in that window and took the whole
// public journey page down with it.
// effect can draw while the map still has no center or zoom. Projecting in that
// window throws and took the whole public journey page down with it.
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
const noView = () => { throw new Error('Set map center and zoom first.'); };
let hasView = false;
@@ -794,12 +950,12 @@ describe('JourneyMap', () => {
// The mock hands out one shared map instance, so it can be reached — and
// stripped of its view — before the component ever builds one. Typed down to
// the three spies this needs: L.map's signature promises a real Leaflet map.
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
type MapProbe = { getCenter: Mock; project: Mock; on: Mock };
L.map(document.createElement('div'));
const map = mockedMap() as MapProbe;
vi.mocked(L.map).mockClear();
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
map.project.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
try {
// Rendering must survive the viewless window, and draw nothing in it.
@@ -820,7 +976,7 @@ describe('JourneyMap', () => {
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
} finally {
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
map.project.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
@@ -868,6 +1024,108 @@ describe('JourneyMap', () => {
expect(divIconHtml(0)).not.toContain('#16a34a');
expect(divIconHtml(1)).toContain('#16a34a');
});
// #2889: a pan used to shift the screen grid the layers cluster on, so photos
// near a cell edge changed groups, counts and thumbnails without any zoom.
it('FE-COMP-JOURNEYMAP-055: panning keeps the photo and mark groups, only a zoom regroups them', () => {
const photos = [
{ id: 'p1', lat: 45, lng: 5, thumbUrl: '/uploads/a.jpg' },
{ id: 'p2', lat: 45, lng: 5.05, thumbUrl: '/uploads/b.jpg' },
];
const marks = [
{ kind: 'stay' as const, key: 's1', lat: 45, lng: 5, color: '#14b8a6', stay: {} as JourneyMapStay },
{ kind: 'stay' as const, key: 's2', lat: 45, lng: 5.05, color: '#14b8a6', stay: {} as JourneyMapStay },
];
L.map(document.createElement('div'));
const map = mockedMap() as { project: Mock; latLngToContainerPoint: Mock; getZoom: Mock; on: Mock; off: Mock };
vi.mocked(L.map).mockClear();
let zoom = 3;
let pan = 0;
map.getZoom.mockImplementation(() => zoom);
map.project.mockImplementation(([lat, lng]: [number, number], z: number) => mercatorWorldPx(lat, lng, z, 256));
map.latLngToContainerPoint.mockImplementation(([lat, lng]: [number, number]) => {
const world = mercatorWorldPx(lat, lng, zoom, 256);
return { x: world.x - pan, y: world.y };
});
const drawnSince = (from: number) =>
vi.mocked(L.divIcon).mock.calls.slice(from).map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/') || h.includes('#14b8a6'));
const moveEnd = () => act(() => {
const gone = new Set(map.off.mock.calls.map(c => c[1]));
for (const [event, handler] of map.on.mock.calls) if (event === 'moveend' && !gone.has(handler)) (handler as () => void)();
});
try {
render(<JourneyMap checkins={[]} entries={[]} photos={photos} marks={marks} />);
let from = vi.mocked(L.divIcon).mock.calls.length;
moveEnd();
const first = drawnSince(from);
expect(first.filter(h => h.includes('/uploads/'))).toEqual([expect.stringContaining('>2<')]);
expect(first.filter(h => h.includes('#14b8a6'))).toHaveLength(1);
expect(map.project).toHaveBeenCalledWith([45, 5.05], 3);
expect(map.latLngToContainerPoint).not.toHaveBeenCalled();
// A pan that puts a 64 px cell edge between the two photos on screen.
const mid = (mercatorWorldPx(45, 5, 3, 256).x + mercatorWorldPx(45, 5.05, 3, 256).x) / 2;
pan = mid - (Math.floor(mid / 64) + 0.5) * 64;
from = vi.mocked(L.divIcon).mock.calls.length;
moveEnd();
expect(drawnSince(from)).toEqual(first);
zoom = 12;
from = vi.mocked(L.divIcon).mock.calls.length;
moveEnd();
const zoomed = drawnSince(from);
expect(zoomed.filter(h => h.includes('/uploads/'))).toHaveLength(2);
expect(zoomed.filter(h => h.includes('#14b8a6'))).toHaveLength(2);
} finally {
map.getZoom.mockImplementation(() => 10);
map.project.mockImplementation(() => ({ x: 0, y: 0 }));
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
describe('the line between the entries on recorded days (#2888)', () => {
const days = [
{ id: 'a', lat: 48.1, lng: 11.5, title: 'A', mood: null, entry_date: '2026-10-29' },
{ id: 'b', lat: 48.2, lng: 11.6, title: 'B', mood: null, entry_date: '2026-10-29' },
{ id: 'c', lat: 48.3, lng: 11.7, title: 'C', mood: null, entry_date: '2026-10-30' },
];
const tracks = [
{ place_id: -1, trip_id: 0, name: '2026-10-29', color: '#7c3aed', points: [[48.1, 11.5], [48.2, 11.6]] as [number, number][], kind: 'dawarich_live' as const, date: '2026-10-29' },
];
const connectionLines = () => {
const polyline = vi.mocked(L.polyline).mock;
return polyline.calls
.map((call, i) => ({ coords: call[0], dashed: (call[1] as { dashArray?: string } | undefined)?.dashArray === '4 6', line: polyline.results[i].value as { addTo: Mock; remove: Mock } }))
.filter(call => call.dashed);
};
it('FE-COMP-JOURNEYMAP-056: leaves the recorded day to its track and keeps the jump to the next day', () => {
render(<JourneyMap checkins={[]} entries={days} tracks={tracks} />);
const lines = connectionLines();
expect(lines.map(l => l.coords)).toEqual([
[[48.2, 11.6], [48.3, 11.7]],
[[48.1, 11.5], [48.2, 11.6]],
]);
expect(lines[0].line.addTo).toHaveBeenCalled();
expect(lines[1].line.addTo).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-057: hiding what was recorded puts the covered stretch back, hiding the entries takes it off again', () => {
mockedMarkerWithRemove();
const shown = { plan: true, recorded: true, entries: true };
const { rerender } = render(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={shown} />);
const covered = connectionLines()[1].line;
rerender(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false }} />);
expect(covered.addTo).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false, entries: false }} />);
expect(covered.remove).toHaveBeenCalledTimes(1);
expect(L.map).toHaveBeenCalledTimes(1);
});
});
});
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
+131 -55
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
@@ -11,7 +11,8 @@ import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
import { NOTHING_DRAWN, drawLeafletMarks, isLeafletLayerShown, leafletWorldPx, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
import { entryConnections } from './journeyMapConnections'
export interface MapMarkerItem {
id: string
@@ -22,7 +23,7 @@ export interface MapMarkerItem {
mood?: string | null
time: string
dayColor: string
dayLabel: number
dayLabel: number | null
photoUrls: string[]
}
@@ -44,7 +45,7 @@ interface MapEntry {
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
dayLabel?: number | null
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
@@ -65,15 +66,15 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
/**
* Leave the marker labels off.
* Open the entry a marker card names when the card itself is tapped.
*
* On the phone the map sits above a carousel whose active card already carries
* the entry's name, and a tooltip on touch is a tap-to-open box rather than a
* hover hint — so it says the same thing twice and covers the map to do it
* (discussion #2299). On desktop the label is the only name a marker has, so
* this stays off there.
* On a phone the card is what a tap on a pin brings up, so it has to lead
* somewhere (#2885). Wiring it is what makes the card take the pointer, and the
* card then stands over the active pin, as the GL popup does; without
* it the card stays a label the map's drags pass through, as on desktop, where
* it is a hover card the mouse cannot reach.
*/
hideMarkerTooltip?: boolean
onMarkerCardClick?: (entryId: string) => void
paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
@@ -116,7 +117,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
mood: e.mood,
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
dayLabel: e.dayLabel === undefined ? 1 : e.dayLabel,
photoUrls: e.photoUrls ?? [],
})
}
@@ -128,12 +129,12 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
function markerSvg(dayColor: string, dayLabel: number | null, highlighted: boolean, planned = false): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(dayLabel)
const label = dayLabel === null ? '' : String(dayLabel)
const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
@@ -184,6 +185,91 @@ function panToMarker(map: L.Map, marker: L.Marker): void {
})
}
/**
* The same card the GL renderer shows, from the same builder: which map engine a
* reader happens to have selected should not change what a marker tells them
* (discussion #2299). The builder escapes everything that came from a person,
* which matters most here, since this map is what the public journey page renders.
*/
function entryCardHtml(item: MapMarkerItem): string {
return journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
})
}
const ENTRY_CARD_OPTIONS: L.TooltipOptions = { direction: 'top', offset: [0, -MARKER_H] }
/**
* The card that opens its entry when tapped (#2885), one per map like the GL
* popup, shown above the active pin.
*
* It is deliberately not bound to the pins. A bound tooltip is opened and closed
* by Leaflet on the pin's hover and focus, and a tap on the card is, to a phone,
* the pointer leaving the pin: the pin's mouseout closed the card between the
* tap and its click. Driven by the active pin alone, nothing but a change of
* entry takes it away.
*/
function createEntryCard(open: () => void): L.Tooltip {
const card = L.tooltip({ ...ENTRY_CARD_OPTIONS, className: 'map-tooltip trek-journey-tooltip is-openable' })
// The element is built on the first open and kept for every later one.
card.once('add', () => {
const el = card.getElement()
if (!el) return
// A press on the card is not the start of a drag or a tap on the map.
L.DomEvent.disableClickPropagation(el)
L.DomEvent.on(el, 'click', (ev) => {
L.DomEvent.stopPropagation(ev)
open()
})
})
return card
}
interface PinRefs {
onCardClickRef: RefObject<((entryId: string) => void) | undefined>
mapRef: RefObject<L.Map | null>
markersRef: RefObject<Map<string, L.Marker>>
itemsRef: RefObject<MapMarkerItem[]>
highlightedRef: RefObject<string | null>
}
/**
* The openable card of a host that wires onMarkerCardClick. The returned sync puts
* it over the active pin while that pin is on the map and takes it off otherwise.
*/
function useEntryCardSync({ onCardClickRef, mapRef, markersRef, itemsRef, highlightedRef }: PinRefs): () => void {
const cardRef = useRef<L.Tooltip | null>(null)
const cardEntryIdRef = useRef<string | null>(null)
const sync = useCallback(() => {
const map = mapRef.current
const id = highlightedRef.current
const marker = id ? markersRef.current.get(id) : undefined
const item = id ? itemsRef.current.find(i => i.id === id) : undefined
if (!onCardClickRef.current || !map || !marker || !item || !map.hasLayer(marker)) {
cardRef.current?.remove()
cardEntryIdRef.current = null
return
}
ensureJourneyPopupStyle()
let card = cardRef.current
if (!card) {
card = createEntryCard(() => {
if (cardEntryIdRef.current) onCardClickRef.current?.(cardEntryIdRef.current)
})
cardRef.current = card
}
cardEntryIdRef.current = item.id
card.setLatLng(marker.getLatLng()).setContent(entryCardHtml(item))
if (!map.hasLayer(card)) card.addTo(map)
}, [onCardClickRef, mapRef, markersRef, itemsRef, highlightedRef])
return sync
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
@@ -200,12 +286,12 @@ function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
}
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// Read through a ref: the flag is fixed per surface, and putting it in the
// Read through a ref: the handler is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
const onMarkerCardClickRef = useRef(onMarkerCardClick)
onMarkerCardClickRef.current = onMarkerCardClick
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
@@ -256,6 +342,8 @@ function JourneyMap(
plannedRef.current = plannedEntryIds
})
const syncEntryCard = useEntryCardSync({ onCardClickRef: onMarkerCardClickRef, mapRef, markersRef, itemsRef, highlightedRef })
const highlightMarker = useCallback((id: string | null) => {
const prev = highlightedRef.current
highlightedRef.current = id
@@ -264,7 +352,8 @@ function JourneyMap(
)
if (prev && prev !== id) paint(prev, false)
if (id) paint(id, true)
}, [])
syncEntryCard()
}, [syncEntryCard])
/**
* Bring an entry's marker under the reader without changing how far out they are.
@@ -347,7 +436,7 @@ function JourneyMap(
iconPlannedRef.current = plannedRef.current
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
drawn[kind].push(layer)
if (visibilityRef.current[kind]) layer.addTo(map)
if (isLeafletLayerShown(kind, visibilityRef.current)) layer.addTo(map)
return layer
}
@@ -403,17 +492,18 @@ function JourneyMap(
coords.forEach(c => trackCoords.push(c))
}
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
place('entries', L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}))
// The faint line between the entries in their order, same as JourneyMapGL, left
// off the stretches a recorded day already draws as travelled (#2888).
const connections = entryConnections(items.map(i => ({ lat: i.lat, lng: i.lng, date: i.time })), stableTracks)
const connectionStyle: L.PolylineOptions = {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}
for (const line of connections.open) place('entries', L.polyline(line, connectionStyle))
for (const line of connections.covered) place('coveredConnections', L.polyline(line, connectionStyle))
// place markers
items.forEach((item, i) => {
@@ -421,26 +511,11 @@ function JourneyMap(
allCoords.push(pos)
const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
// The same card the GL renderer shows, from the same builder: which map
// engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came
// from a person, which matters most here — this map is what the public
// journey page renders.
if (!hideMarkerTooltipRef.current) {
// A host that opens the entry from its card gets the one card that follows
// the active pin instead (see syncEntryCard).
if (!onMarkerCardClickRef.current) {
ensureJourneyPopupStyle()
marker.bindTooltip(
journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
}),
{
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip trek-journey-tooltip',
},
)
marker.bindTooltip(entryCardHtml(item), { ...ENTRY_CARD_OPTIONS, className: 'map-tooltip trek-journey-tooltip' })
}
marker.on('click', () => {
@@ -487,7 +562,8 @@ function JourneyMap(
// A switch puts its layer back on the map or takes it off, exactly as drawn.
useEffect(() => {
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
}, [layerVisibility])
syncEntryCard()
}, [layerVisibility, syncEntryCard])
// The plan's check on an entry pin follows the plan layer without redrawing the map.
useEffect(() => {
@@ -509,8 +585,8 @@ function JourneyMap(
}, [tileUrl])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
// pan because the clustering is done in screen space.
// down and rebuild the map the way the entry effect does. Redrawn after every
// move, but the clusters sit on a world grid, so only a zoom regroups them.
useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -522,7 +598,7 @@ function JourneyMap(
// The initial view (setView/fitBounds) is set on a deferred rAF in the
// map-build effect above, so this can run before the map has a center/zoom
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
// and nothing can be projected in that window. Skip; the moveend/zoomend
// listener below redraws once the deferred view lands.
try {
map.getCenter()
@@ -531,7 +607,7 @@ function JourneyMap(
}
const group = L.layerGroup()
for (const cluster of clusterPhotos(photos, (lat, lng) => map.latLngToContainerPoint([lat, lng]))) {
for (const cluster of clusterPhotos(photos, leafletWorldPx(map))) {
const marker = L.marker([cluster.lat, cluster.lng], {
icon: L.divIcon({
className: '',
@@ -563,7 +639,7 @@ function JourneyMap(
// The plan's places and the stays of day reports (JRN-04), in their own layer
// like the photos: below the entry pins and above the thumbnails, regrouped
// on every move because marks are only joined where they would overlap.
// on zoom because marks are only joined where they would overlap.
useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -23,7 +23,8 @@ interface MapEntry {
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
/** Null leaves the pin unnumbered: a draft the share link does not count. */
dayLabel?: number | null
}
interface Props {
@@ -38,7 +39,8 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
hideMarkerTooltip?: boolean
/** Open the entry a marker card names when the card is tapped, both renderers. */
onMarkerCardClick?: (entryId: string) => void
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn>
@@ -35,8 +35,9 @@ const gl = vi.hoisted(() => {
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn(() => true),
// Container pixels from lng/lat, ten per degree: enough to tell near from far.
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
// Container pixels, ten per degree. The layers must not group by them: they
// shift with every pan (#2889).
project: vi.fn(),
remove: vi.fn(),
resize: vi.fn(),
flyTo: vi.fn(),
@@ -182,6 +183,7 @@ beforeEach(() => {
return gl.map
})
gl.map.getZoom.mockReturnValue(10)
gl.map.project.mockImplementation(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 }))
gl.map.getSource.mockReturnValue(null)
vi.mocked(isStandardFamily).mockReturnValue(false)
vi.mocked(supportsCustom3d).mockReturnValue(false)
@@ -236,6 +238,15 @@ describe('JourneyMapGL', () => {
expect(gl.markers[1].element.innerHTML).toContain('>1<')
})
it('FE-COMP-JMAPGL-043: a stop without a number (a draft) gets an unnumbered pin', () => {
withToken()
render(<JourneyMapGL checkins={[]} entries={[{ ...entries[0], dayLabel: null }]} />)
expect(gl.markers).toHaveLength(1)
expect(gl.markers[0].element.innerHTML).toContain('#ff0055')
expect(gl.markers[0].element.innerHTML).toMatch(/font-weight="700"><\/text>/)
})
it('FE-COMP-JMAPGL-006: entries without coordinates never reach the map', () => {
withToken()
render(
@@ -253,25 +264,14 @@ describe('JourneyMapGL', () => {
render(<JourneyMapGL checkins={[]} entries={entries} />)
expect(gl.map.addSource).toHaveBeenCalledWith('journey-route', expect.objectContaining({ type: 'geojson' }))
const source = vi.mocked(gl.map.addSource).mock.calls[0][1] as { data: { geometry: { coordinates: number[][] } } }
expect(source.data.geometry.coordinates).toEqual([[2.3522, 48.8566], [13.405, 52.52]])
const source = vi.mocked(gl.map.addSource).mock.calls[0][1] as { data: { geometry: { coordinates: number[][][] } } }
expect(source.data.geometry.coordinates).toEqual([[[2.3522, 48.8566], [13.405, 52.52]]])
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
id: 'journey-route-line',
source: 'journey-route',
}))
})
it('FE-COMP-JMAPGL-008: an existing route source is updated instead of re-added', () => {
withToken()
const setData = vi.fn()
gl.map.getSource.mockReturnValue({ setData })
render(<JourneyMapGL checkins={[]} entries={entries} />)
expect(gl.map.addSource).not.toHaveBeenCalled()
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
})
it('FE-COMP-JMAPGL-038: a planned GPX track goes on the dashed layer, a recorded route on the solid one (JRN-01)', () => {
withToken()
render(
@@ -507,6 +507,86 @@ describe('JourneyMapGL', () => {
expect(document.querySelectorAll('#trek-journey-popup-style')).toHaveLength(1)
})
describe('the marker card as a way into the entry (#2885)', () => {
const withPhotos = [
{ ...entries[0], photoUrls: ['/a.jpg', '/b.jpg'] },
entries[1],
]
// The stub's setHTML records the markup; the card's content is put in by hand.
function openCard(props: Record<string, unknown>, id = 'e1') {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
render(<JourneyMapGL ref={ref} checkins={[]} entries={withPhotos} {...props} />)
act(() => { ref.current?.highlightMarker(id) })
const popup = gl.popups[0]
popup.element.innerHTML = String(popup.setHTML.mock.lastCall?.[0])
const mapClick = vi.fn()
const host = document.createElement('div')
host.appendChild(popup.element)
host.addEventListener('click', mapClick)
return { ref, popup, mapClick }
}
function tap(popup: PopupStub, selector: string) {
const target = popup.element.querySelector(selector) as HTMLElement
target.click()
}
it('FE-COMP-JMAPGL-044: a tap anywhere on the card opens its entry and stays off the map', () => {
const onMarkerCardClick = vi.fn()
const { popup, mapClick } = openCard({ onMarkerCardClick })
expect(popup.options.className).toContain('is-openable')
tap(popup, '.trek-journey-popup-title')
expect(onMarkerCardClick).toHaveBeenCalledWith('e1')
expect(mapClick).not.toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-045: with a photo handler a thumbnail opens the photo, not the entry', () => {
const onMarkerCardClick = vi.fn()
const onMarkerPhotoClick = vi.fn()
const { popup } = openCard({ onMarkerCardClick, onMarkerPhotoClick })
tap(popup, '[data-shot="1"]')
expect(onMarkerPhotoClick).toHaveBeenCalledWith('e1', 1)
expect(onMarkerCardClick).not.toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-046: without a photo handler a thumbnail opens the entry like the rest of the card', () => {
const onMarkerCardClick = vi.fn()
const { popup } = openCard({ onMarkerCardClick })
tap(popup, '[data-shot]')
expect(onMarkerCardClick).toHaveBeenCalledWith('e1')
})
it('FE-COMP-JMAPGL-047: without a handler the card stays a label the tap passes through', () => {
const { popup, mapClick } = openCard({})
expect(popup.options.className).toBe('trek-journey-popup')
tap(popup, '.trek-journey-popup-title')
expect(mapClick).toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-048: the reused card opens the entry it shows now', () => {
const onMarkerCardClick = vi.fn()
const { ref, popup } = openCard({ onMarkerCardClick })
act(() => { ref.current?.highlightMarker('e2') })
popup.element.innerHTML = String(popup.setHTML.mock.lastCall?.[0])
tap(popup, '.trek-journey-popup-title')
expect(popup.element.classList.contains('is-openable')).toBe(true)
expect(popup.element.classList.contains('is-interactive')).toBe(false)
expect(onMarkerCardClick).toHaveBeenCalledWith('e2')
})
})
it('FE-COMP-JMAPGL-023: focusMarker eases over with the 3D pitch and no zoom of its own', () => {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
@@ -553,9 +633,10 @@ describe('JourneyMapGL', () => {
{ id: 'b', lat: 45.1, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
{ id: 'c', lat: 48, lng: 11, thumbUrl: '/api/photos/3/thumbnail' },
]
gl.map.getZoom.mockReturnValue(4)
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} onPhotoClick={onPhotoClick} />)
const thumbs = gl.markers.filter(m => m.element.innerHTML.includes('/api/photos/'))
// a and b fall in one 64px bucket at ten pixels a degree, c in its own.
// At zoom 4 a and b lie three world pixels apart in one 64px cell, c far off.
expect(thumbs).toHaveLength(2)
expect(thumbs[0].element.innerHTML).toContain('>2<')
thumbs[0].element.click()
@@ -563,6 +644,45 @@ describe('JourneyMapGL', () => {
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
})
it('FE-COMP-JMAPGL-2889: panning keeps the photo and mark groups, only a zoom regroups them', () => {
withToken()
const photos = [
{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' },
{ id: 'b', lat: 45, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
]
const marks = [
{ kind: 'stay', key: 's1', lat: 45, lng: 5, color: '#14b8a6', stay: {} },
{ kind: 'stay', key: 's2', lat: 45, lng: 5.1, color: '#14b8a6', stay: {} },
]
gl.map.getZoom.mockReturnValue(4)
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} marks={marks} />)
const drawn = () => {
const shown = gl.markers.filter(m => !m.remove.mock.calls.length)
return {
photos: shown.filter(m => m.element.innerHTML.includes('/api/photos/')).map(m => [m.lngLat, m.element.innerHTML]),
marks: shown.filter(m => m.element.innerHTML.includes('#14b8a6')).map(m => [m.lngLat, m.element.innerHTML]),
}
}
const moveEnd = () => act(() => {
const gone = new Set(gl.map.off.mock.calls.map(c => c[1]))
for (const [event, handler] of gl.map.on.mock.calls) if (event === 'moveend' && !gone.has(handler)) (handler as () => void)()
})
const before = drawn()
expect(before.photos).toHaveLength(1)
expect(before.photos[0][1]).toContain('>2<')
expect(before.marks).toHaveLength(1)
// A pan moves every container point; at this offset b crosses a cell edge on screen.
gl.map.project.mockImplementation(([lng, lat]: [number, number]) => ({ x: lng * 10 - 19, y: lat * 10 }))
moveEnd()
expect(drawn()).toEqual(before)
gl.map.getZoom.mockReturnValue(12)
moveEnd()
expect(drawn().photos).toHaveLength(2)
expect(drawn().marks).toHaveLength(2)
})
it('FE-COMP-JMAPGL-026: focusMarker on an unknown id is a no-op', () => {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
@@ -722,4 +842,45 @@ describe('JourneyMapGL', () => {
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
})
it('FE-COMP-JMAPGL-041: a recorded day takes the line between its entries off the map, the rest stays (#2888)', () => {
withToken()
const days = [
{ id: 'a', lat: 48.1, lng: 11.5, entry_date: '2026-10-29' },
{ id: 'b', lat: 48.2, lng: 11.6, entry_date: '2026-10-29' },
{ id: 'c', lat: 48.3, lng: 11.7, entry_date: '2026-10-30' },
{ id: 'd', lat: 48.4, lng: 11.8, entry_date: '2026-10-30' },
]
render(
<JourneyMapGL
checkins={[]}
entries={days}
tracks={[{ place_id: -1, trip_id: 0, name: '2026-10-29', color: '#7c3aed', points: [[48.1, 11.5], [48.2, 11.6]], kind: 'dawarich_live', date: '2026-10-29' }]}
/>,
)
const data = (id: string) => (vi.mocked(gl.map.addSource).mock.calls.find(c => c[0] === id)?.[1] as { data: { geometry: { coordinates: number[][][] } } }).data.geometry.coordinates
expect(data('journey-route')).toEqual([[[11.6, 48.2], [11.7, 48.3], [11.8, 48.4]]])
expect(data('journey-route-covered')).toEqual([[[11.5, 48.1], [11.6, 48.2]]])
const covered = vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown> }).find(l => l.id === 'journey-route-covered-line')
expect(covered?.layout.visibility).toBe('none')
})
it('FE-COMP-JMAPGL-042: hiding what was recorded brings the covered stretches back while the entries show', () => {
withToken()
const shown = { plan: true, recorded: true, entries: true }
const days = [
{ id: 'a', lat: 48.1, lng: 11.5, entry_date: '2026-10-29' },
{ id: 'b', lat: 48.2, lng: 11.6, entry_date: '2026-10-29' },
]
const tracks = [{ place_id: -1, trip_id: 0, name: 'Day', color: null, points: [[48.1, 11.5], [48.2, 11.6]] as [number, number][], kind: 'day_recording' as const, date: '2026-10-29' }]
const { rerender } = render(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={shown} />)
expect(gl.map.addSource).not.toHaveBeenCalledWith('journey-route', expect.anything())
rerender(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false }} />)
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-route-covered-line', 'visibility', 'visible')
rerender(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false, entries: false }} />)
expect(gl.map.setLayoutProperty).toHaveBeenLastCalledWith('journey-route-covered-line', 'visibility', 'none')
})
})
+71 -51
View File
@@ -7,8 +7,9 @@ import { buildGlMap } from '../Map/glSupport'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, glWorldPx, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
import { coveredConnectionsShown, entryConnections } from './journeyMapConnections'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -25,7 +26,7 @@ interface MapEntry {
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
dayLabel?: number | null
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
@@ -42,8 +43,8 @@ interface Props {
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
hideMarkerTooltip?: boolean
/** See the Leaflet twin: a tap on the card opens the entry it names (#2885). */
onMarkerCardClick?: (entryId: string) => void
/**
* Open the entry's photos from the card's thumbnail strip.
*
@@ -78,7 +79,7 @@ interface Item {
locationName: string
time: string
dayColor: string
dayLabel: number
dayLabel: number | null
photoUrls: string[]
}
@@ -97,7 +98,7 @@ function buildItems(entries: MapEntry[]): Item[] {
locationName: e.location_name || '',
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
dayLabel: e.dayLabel === undefined ? 1 : e.dayLabel,
photoUrls: e.photoUrls ?? [],
})
}
@@ -114,8 +115,35 @@ const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
}
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
/** The stretches between entries a recorded day already draws; shown only while that recording is hidden. */
const COVERED_CONNECTION_LAYER = 'journey-route-covered-line'
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
/** One of the faint lines between the entries, as a dashed layer over its own source. */
function addConnectionLayer(map: mapboxgl.Map, id: string, lines: [number, number][][], dark: boolean, visible: boolean) {
if (lines.length === 0) return
map.addSource(id, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'MultiLineString', coordinates: lines.map(line => line.map(([lat, lng]) => [lng, lat])) },
},
})
map.addLayer({
id: `${id}-line`,
type: 'line',
source: id,
paint: {
'line-color': dark ? '#71717A' : '#A1A1AA',
'line-width': 1.5,
'line-opacity': 0.5,
'line-dasharray': [2, 3],
},
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: visible ? 'visible' : 'none' },
})
}
function markerHtml(dayColor: string, dayLabel: number | null, highlighted: boolean, planned = false): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
@@ -123,7 +151,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, pl
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1
const label = String(dayLabel)
const label = dayLabel === null ? '' : String(dayLabel)
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise
@@ -166,13 +194,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
}
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
const onMarkerCardClickRef = useRef(onMarkerCardClick)
onMarkerCardClickRef.current = onMarkerCardClick
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
onMarkerPhotoClickRef.current = onMarkerPhotoClick
/** Which entry the card is currently describing, for the delegated photo click. */
/** Which entry the card is currently describing, for the delegated card and photo clicks. */
const popupItemIdRef = useRef<string | null>(null)
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
@@ -224,9 +252,6 @@ function JourneyMapGL(
mapLangRef.current = mapLang
const showPopup = useCallback((id: string) => {
// See `hideMarkerTooltip` on the Leaflet twin: below the carousel the card
// already says this, and the popup only covers the map to repeat it.
if (hideMarkerTooltipRef.current) return
const item = itemsRef.current.find(i => i.id === id)
if (!item || !mapRef.current) return
ensureJourneyPopupStyle()
@@ -244,6 +269,7 @@ function JourneyMapGL(
const offset: [number, number] = [0, -46]
popupItemIdRef.current = item.id
const interactive = !!onMarkerPhotoClickRef.current && item.photoUrls.length > 0
const openable = !!onMarkerCardClickRef.current
if (popupRef.current) {
popupRef.current.setLngLat([item.lng, item.lat])
popupRef.current.setHTML(html)
@@ -252,6 +278,7 @@ function JourneyMapGL(
if (el) {
el.classList.toggle('trek-dark', !!darkRef.current)
el.classList.toggle('is-interactive', interactive)
el.classList.toggle('is-openable', openable)
}
} else {
popupRef.current = new gl.Popup({
@@ -260,7 +287,7 @@ function JourneyMapGL(
closeOnMove: false,
anchor: 'bottom',
offset,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}`,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}${openable ? ' is-openable' : ''}`,
maxWidth: '280px',
})
.setLngLat([item.lng, item.lat])
@@ -270,11 +297,18 @@ function JourneyMapGL(
// strip on every entry, so a listener on the thumbnails themselves would have
// to be rebound each time and would leak the ones it forgot.
popupRef.current.getElement()?.addEventListener('click', (ev: Event) => {
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
const entryId = popupItemIdRef.current
if (!shot || !entryId) return
ev.stopPropagation()
onMarkerPhotoClickRef.current?.(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
if (!entryId) return
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
const onPhoto = onMarkerPhotoClickRef.current
const onCard = onMarkerCardClickRef.current
if (shot && onPhoto) {
ev.stopPropagation()
onPhoto(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
} else if (onCard) {
ev.stopPropagation()
onCard(entryId)
}
})
}
}, [gl])
@@ -397,31 +431,12 @@ function JourneyMapGL(
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLangRef.current)) } catch { /* style/SDK may not support it */ }
}
// route trail — dashed line connecting entries in time order
if (items.length > 1) {
const coords = items.map(i => [i.lng, i.lat])
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
})
else {
map.addSource('journey-route', {
type: 'geojson',
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
})
map.addLayer({
id: 'journey-route-line',
type: 'line',
source: 'journey-route',
paint: {
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
'line-width': 1.5,
'line-opacity': 0.5,
'line-dasharray': [2, 3],
},
layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
})
}
}
// The faint line between the entries in their order, left off the stretches
// a recorded day already draws as travelled (#2888). Those go on a layer of
// their own that stands in while the reader hides the recording.
const connections = entryConnections(items.map(i => ({ lat: i.lat, lng: i.lng, date: i.time })), stableTracks)
addConnectionLayer(map, 'journey-route', connections.open, !!darkRef.current, visibilityRef.current.entries)
addConnectionLayer(map, 'journey-route-covered', connections.covered, !!darkRef.current, coveredConnectionsShown(visibilityRef.current))
// Tracks, one source for all of them, coloured per feature so a journey
// with several routes keeps them apart. Casing underneath, same as the
@@ -526,9 +541,10 @@ function JourneyMapGL(
}, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Photo layer (#2453), the GL twin of the Leaflet one: its own effect so photos
// arriving do not rebuild the map, redrawn on every move because the clustering
// is done in screen space. Same dependencies as the build effect above, so it
// attaches to the map that effect just made.
// arriving do not rebuild the map. Redrawn after every move, but the clusters
// sit on a world grid, so only a zoom regroups them; a pan, a turn or a tilt
// leaves them as they were. Same dependencies as the build effect above, so
// it attaches to the map that effect just made.
useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -539,7 +555,7 @@ function JourneyMapGL(
const draw = () => {
clear()
if (!photos?.length) return
for (const cluster of clusterPhotos(photos, (lat, lng) => map.project([lng, lat]))) {
for (const cluster of clusterPhotos(photos, glWorldPx(map))) {
const el = document.createElement('div')
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
@@ -575,6 +591,10 @@ function JourneyMapGL(
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
}
}
const coveredShown = coveredConnectionsShown(layerVisibility)
if (coveredConnectionsShown(shownRef.current) !== coveredShown) {
try { map.setLayoutProperty(COVERED_CONNECTION_LAYER, 'visibility', coveredShown ? 'visible' : 'none') } catch { /* not drawn on this map */ }
}
shownRef.current = layerVisibility
}, [layerVisibility])
@@ -589,7 +609,7 @@ function JourneyMapGL(
}, [plannedEntryIds, setMarkerStyle])
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
// regrouped on every move, between the photos and the entry pins.
// regrouped on zoom, between the photos and the entry pins.
useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -608,7 +628,7 @@ function JourneyMapGL(
} catch { /* empty bounds */ }
}
}
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
for (const hit of clusterPoints(marks, glWorldPx(map), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members)
const el = document.createElement('div')
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
@@ -1,4 +1,4 @@
// FE-COMP-JMAPTL-001 to FE-COMP-JMAPTL-016
// FE-COMP-JMAPTL-001 to FE-COMP-JMAPTL-018
const mapHandle = vi.hoisted(() => ({
highlightMarker: vi.fn(),
@@ -190,6 +190,16 @@ describe('MobileMapTimeline', () => {
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
})
it('FE-COMP-JMAPTL-018: a tap on a marker card opens that entry straight away (#2885)', () => {
const onEntryClick = vi.fn()
renderTimeline({ onEntryClick })
const onMarkerCardClick = lastMapProps.current.onMarkerCardClick as (id: string) => void
act(() => { onMarkerCardClick('2') })
expect(onEntryClick).toHaveBeenCalledWith(entries[1])
})
it('FE-COMP-JMAPTL-007: a marker click for an unknown entry is ignored', () => {
renderTimeline()
const onMarkerClick = lastMapProps.current.onMarkerClick as (id: string) => void
@@ -70,7 +70,7 @@ export default function MobileMapTimeline({
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, jumpToDay,
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
// The delayed initial focus reads both through refs so it always works off
// the current map entries, not the ones from the render that armed the timer.
@@ -169,6 +169,7 @@ export default function MobileMapTimeline({
dark={dark}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
onMarkerCardClick={openEntryById}
fullScreen
paddingBottom={250}
cartoApiKey={cartoApiKey}
@@ -1,12 +1,21 @@
import L from 'leaflet';
import { coveredConnectionsShown } from './journeyMapConnections';
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark, type WorldPx } from './journeyMapMarks';
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
/**
* The layers of the Leaflet journey map a switch takes off and puts back (JRN-04),
* plus the stretches between entries a recording already shows, which follow two.
*/
export type LeafletLayerKind = keyof JourneyMapLayerVisibility | 'coveredConnections';
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [], coveredConnections: [] });
/** Whether a layer of this kind is on the map with these switches. */
export function isLeafletLayerShown(kind: LeafletLayerKind, visibility: JourneyMapLayerVisibility): boolean {
return kind === 'coveredConnections' ? coveredConnectionsShown(visibility) : visibility[kind];
}
/**
* Puts each layer whose switch changed back on the map or takes it off,
@@ -20,15 +29,25 @@ export function switchLeafletLayers(
): JourneyMapLayerVisibility {
if (!map) return shown;
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
if (shown[kind] === next[kind]) continue;
const visible = isLeafletLayerShown(kind, next);
if (isLeafletLayerShown(kind, shown) === visible) continue;
for (const layer of drawn[kind]) {
if (next[kind]) layer.addTo(map);
if (visible) layer.addTo(map);
else layer.remove();
}
}
return next;
}
/**
* World pixels at the map's current zoom, through the map's own CRS so a
* GCJ-02 basemap groups where it draws. Only meaningful once the map has a view.
*/
export function leafletWorldPx(map: L.Map): WorldPx {
const zoom = map.getZoom();
return (lat, lng) => map.project([lat, lng], zoom);
}
/**
* The plan's places and the stays as one layer: below the entry pins, above
* the photo thumbnails, joined only where they would overlap on screen.
@@ -47,7 +66,7 @@ export function drawLeafletMarks(
return null;
}
const group = L.layerGroup();
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
for (const hit of clusterPoints(marks, leafletWorldPx(map), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members);
const marker = L.marker([hit.lat, hit.lng], {
icon: L.divIcon({
@@ -0,0 +1,117 @@
// FE-JRN-CONN-001 to FE-JRN-CONN-008
import type { JourneyTrack } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { coveredConnectionsShown, entryConnections } from './journeyMapConnections';
const stop = (lat: number, date: string) => ({ lat, lng: lat, date });
function recorded(
date: string,
kind: JourneyTrack['kind'] = 'day_recording',
points: [number, number][] = [
[1, 1],
[2, 2],
]
): JourneyTrack {
return { place_id: -1, trip_id: 0, name: date, color: null, points, kind, date };
}
describe('entryConnections (#2888)', () => {
it('FE-JRN-CONN-001: without a recording the whole line stays open, one polyline through every stop', () => {
const result = entryConnections([stop(1, '2026-10-29'), stop(2, '2026-10-29'), stop(3, '2026-10-30')], []);
expect(result.open).toEqual([
[
[1, 1],
[2, 2],
[3, 3],
],
]);
expect(result.covered).toEqual([]);
});
it('FE-JRN-CONN-002: a recorded day covers the stretches between its entries, the jump into a day without one stays open', () => {
const result = entryConnections(
[stop(1, '2026-10-29'), stop(2, '2026-10-29'), stop(3, '2026-10-30'), stop(4, '2026-10-30')],
[recorded('2026-10-29', 'dawarich_live')]
);
expect(result.covered).toEqual([
[
[1, 1],
[2, 2],
],
]);
expect(result.open).toEqual([
[
[2, 2],
[3, 3],
[4, 4],
],
]);
});
it('FE-JRN-CONN-003: the night between two recorded days is covered, a day in between without one is not', () => {
const tracks = [recorded('2026-10-29'), recorded('2026-10-30'), recorded('2026-11-01')];
expect(entryConnections([stop(1, '2026-10-29'), stop(2, '2026-10-30')], tracks).open).toEqual([]);
expect(entryConnections([stop(1, '2026-10-30'), stop(2, '2026-11-01')], tracks).covered).toEqual([]);
});
it('FE-JRN-CONN-004: a trip GPX, a line without a day and a recording without points cover nothing', () => {
const stops = [stop(1, '2026-10-29'), stop(2, '2026-10-29')];
const gpx: JourneyTrack = { ...recorded('2026-10-29'), kind: 'trip_gpx' };
const undated: JourneyTrack = { ...recorded('2026-10-29'), date: undefined };
const empty = recorded('2026-10-29', 'day_recording', [[1, 1]]);
expect(entryConnections(stops, [gpx, undated, empty]).covered).toEqual([]);
});
it('FE-JRN-CONN-005: open and covered stretches alternate in the order of the stops', () => {
const result = entryConnections(
[
stop(1, '2026-10-28'),
stop(2, '2026-10-29'),
stop(3, '2026-10-29'),
stop(4, '2026-10-31'),
stop(5, '2026-10-31'),
],
[recorded('2026-10-29'), recorded('2026-10-31')]
);
expect(result.open).toEqual([
[
[1, 1],
[2, 2],
],
[
[3, 3],
[4, 4],
],
]);
expect(result.covered).toEqual([
[
[2, 2],
[3, 3],
],
[
[4, 4],
[5, 5],
],
]);
});
it('FE-JRN-CONN-006: a timestamp is read by its day, and an unreadable date never counts as recorded', () => {
expect(
entryConnections([stop(1, '2026-10-29T08:00:00Z'), stop(2, '2026-10-29')], [recorded('2026-10-29')]).open
).toEqual([]);
expect(entryConnections([stop(1, 'someday'), stop(2, '2026-10-29')], [recorded('2026-10-29')]).covered).toEqual([]);
});
it('FE-JRN-CONN-007: fewer than two stops make no line', () => {
expect(entryConnections([stop(1, '2026-10-29')], [])).toEqual({ open: [], covered: [] });
});
});
describe('coveredConnectionsShown', () => {
it('FE-JRN-CONN-008: stands in only while the entries show and the recording is hidden', () => {
expect(coveredConnectionsShown({ plan: true, recorded: true, entries: true })).toBe(false);
expect(coveredConnectionsShown({ plan: true, recorded: false, entries: true })).toBe(true);
expect(coveredConnectionsShown({ plan: true, recorded: false, entries: false })).toBe(false);
});
});
@@ -0,0 +1,93 @@
import type { JourneyTrack } from '@trek/shared';
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers';
/** A point of the line between entries, `[lat, lng]`. */
type LatLng = [number, number];
/** An entry pin as the connection sees it: where it stands and on which day. */
interface ConnectionStop {
lat: number;
lng: number;
/** `YYYY-MM-DD`; a longer ISO string is read by its date. */
date: string;
}
/**
* The faint line between the entries, cut where the map already shows how the
* traveller got from one to the next.
*
* `open` is every stretch nothing else on the map accounts for, so the
* straight line is the best guess there is. `covered` is every stretch whose
* days were all recorded: the recorded line is the real way, and a straight
* one across it only gets in its way (#2888). It is still handed back, so a
* reader who hides what was recorded gets the guess back for those stretches.
*/
interface EntryConnections {
open: LatLng[][];
covered: LatLng[][];
}
const DAY_MS = 86_400_000;
function dayNumber(date: string): number | null {
const time = Date.parse(`${date.slice(0, 10)}T00:00:00Z`);
return Number.isNaN(time) ? null : Math.round(time / DAY_MS);
}
/** The days a recorded line with points stands for; a trip's GPX is the plan and stands for none. */
function recordedDays(tracks: readonly JourneyTrack[]): Set<number> {
const days = new Set<number>();
for (const track of tracks) {
if (isPlannedTrack(track) || !track.date || track.points.length < 2) continue;
const day = dayNumber(track.date);
if (day !== null) days.add(day);
}
return days;
}
/**
* Whether the way between two stops is on the map already: every day from the
* first stop's to the second's was recorded, the night between two recorded
* days included. One day in between without a recording leaves it open.
*/
function travelled(from: string, to: string, recorded: ReadonlySet<number>): boolean {
const a = dayNumber(from);
const b = dayNumber(to);
if (a === null || b === null) return false;
const first = Math.min(a, b);
const last = Math.max(a, b);
if (last - first + 1 > recorded.size) return false;
for (let day = first; day <= last; day++) {
if (!recorded.has(day)) return false;
}
return true;
}
/**
* Splits the line through the stops, taken in the order given (the pins'
* order on the map), into the stretches still open and those a recording
* covers. Neighbouring stretches of the same kind stay one line.
*/
export function entryConnections(stops: readonly ConnectionStop[], tracks: readonly JourneyTrack[]): EntryConnections {
const result: EntryConnections = { open: [], covered: [] };
const recorded = recordedDays(tracks);
let line: LatLng[] | null = null;
let lineCovered = false;
for (let i = 1; i < stops.length; i++) {
const from = stops[i - 1];
const to = stops[i];
const covered = travelled(from.date, to.date, recorded);
if (!line || covered !== lineCovered) {
line = [[from.lat, from.lng]];
(covered ? result.covered : result.open).push(line);
lineCovered = covered;
}
line.push([to.lat, to.lng]);
}
return result;
}
/** The covered stretches stand in for the recording only while the pins are shown and the recording is not. */
export function coveredConnectionsShown(visibility: JourneyMapLayerVisibility): boolean {
return visibility.entries && !visibility.recorded;
}
@@ -1,4 +1,4 @@
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-005
//
// JRN-04: the plan and stay marks both renderers draw, and how they join.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
@@ -6,7 +6,9 @@ import { describe, expect, it } from 'vitest';
import {
MARK_CLUSTER_PX,
clusterPoints,
glWorldPx,
markHitSvg,
mercatorWorldPx,
planPinSvg,
safeColor,
type JourneyMapMark,
@@ -54,4 +56,39 @@ describe('journey map marks', () => {
expect(safeColor(null)).toBe('#64748b');
expect(planPinSvg('red" onload="x')).not.toContain('onload');
});
it('FE-JOURNEY-MAPMARKS-004: world pixels are Web Mercator at the zoom, doubling per step and clamped at the poles', () => {
expect(mercatorWorldPx(0, 0, 0, 512)).toEqual({ x: 256, y: 256 });
expect(mercatorWorldPx(0, -180, 0, 512).x).toBe(0);
const z5 = mercatorWorldPx(35.01, 135.68, 5, 512);
const z6 = mercatorWorldPx(35.01, 135.68, 6, 512);
expect(z6.x).toBeCloseTo(z5.x * 2, 6);
expect(z6.y).toBeCloseTo(z5.y * 2, 6);
expect(mercatorWorldPx(90, 0, 0, 512).y).toBeCloseTo(0, 3);
expect(mercatorWorldPx(-90, 0, 0, 512).y).toBeCloseTo(512, 3);
});
it('FE-JOURNEY-MAPMARKS-005: groups on the GL world grid hold however the view is panned, and split on zoom (#2889)', () => {
const marks = [planMark('a', 35.0), planMark('b', 35.0005), planMark('c', 35.001)];
const keys = (hits: { members: JourneyMapMark[] }[]) => hits.map((h) => h.members.map((m) => m.key));
// What a container point does: the same world position, less the pan.
const onScreen = (pan: number) =>
keys(
clusterPoints(
marks,
(lat, lng) => {
const world = mercatorWorldPx(lat, lng, 13, 512);
return { x: world.x, y: world.y - pan };
},
MARK_CLUSTER_PX
)
);
const screenGroupings = new Set(Array.from({ length: MARK_CLUSTER_PX }, (_, pan) => JSON.stringify(onScreen(pan))));
expect(screenGroupings.size).toBeGreaterThan(1);
const atZoom = (zoom: number) => keys(clusterPoints(marks, glWorldPx({ getZoom: () => zoom }), MARK_CLUSTER_PX));
expect(atZoom(13)).toEqual([['a', 'b', 'c']]);
expect(atZoom(18)).toEqual([['a'], ['b'], ['c']]);
});
});
@@ -15,7 +15,7 @@ export type JourneyMapMark =
export const PLAN_FALLBACK_COLOR = '#64748b';
/**
* Marks closer than this on screen are one hit: the reader could not pick one
* Marks in one cell of this size are one hit: the reader could not pick one
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
* thumbnail, so marks a street apart stay apart.
*/
@@ -27,19 +27,49 @@ const STAY_DOT = 14;
const MARK_CLUSTER_SIZE = 28;
/**
* Grid clustering in screen space, shared by both renderers: points whose
* projected position falls in the same `px` cell become one, anchored on the
* first so the pin points where that member is. `project` is the engine's own
* lat/lng to container-pixel conversion.
* A position in world pixels: the whole map laid out at the current zoom, with
* the origin at its north-west corner. Unlike a container point it does not
* move when the map is panned, rotated or tilted.
*/
export type WorldPx = (lat: number, lng: number) => { x: number; y: number };
const MAX_MERCATOR_LAT = 85.0511287798;
/**
* Web Mercator world pixels at `zoom` for a tile of `tileSize` pixels. Both GL
* engines lay out their world on 512 px tiles; Leaflet projects through its
* own CRS instead, so a GCJ-02 basemap keeps its offset.
*/
export function mercatorWorldPx(lat: number, lng: number, zoom: number, tileSize: number): { x: number; y: number } {
const size = tileSize * 2 ** zoom;
const phi = (Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat)) * Math.PI) / 180;
return {
x: ((lng + 180) / 360) * size,
y: ((1 - Math.log(Math.tan(phi) + 1 / Math.cos(phi)) / Math.PI) / 2) * size,
};
}
/** World pixels at a GL map's current zoom, free of its centre, bearing and pitch. */
export function glWorldPx(map: { getZoom(): number }): WorldPx {
const zoom = map.getZoom();
return (lat, lng) => mercatorWorldPx(lat, lng, zoom, 512);
}
/**
* Grid clustering shared by both renderers: points whose world position falls
* in the same `px` cell become one, anchored on the first so the pin points
* where that member is. The grid is laid over the world and not over the
* viewport, so panning keeps every group as it is and only a zoom regroups
* (#2889).
*/
export function clusterPoints<T extends { lat: number; lng: number }>(
points: readonly T[],
project: (lat: number, lng: number) => { x: number; y: number },
toWorldPx: WorldPx,
px: number
): { lat: number; lng: number; members: T[] }[] {
const buckets = new Map<string, T[]>();
for (const point of points) {
const pt = project(point.lat, point.lng);
const pt = toWorldPx(point.lat, point.lng);
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
const list = buckets.get(key);
if (list) list.push(point);
@@ -93,11 +93,24 @@ export function ensureJourneyPopupStyle(): void {
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
/* The card as a whole never takes the pointer — it sits over the map and would
swallow drags. The photo strip is the one exception, and only where the host
wired a handler for it. */
/* The card sits over the map and takes the pointer only where the host gave it
an action: the whole card where it opens its entry (the phone, where a tap on
a pin is what brings it up), the photo strip where it opens the pictures.
Anywhere else it stays a label the map's drags pass through. */
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content { pointer-events: auto; cursor: pointer; }
.trek-journey-popup.is-interactive .trek-journey-popup-shots { pointer-events: auto; cursor: zoom-in; }
.trek-journey-popup.is-interactive .trek-journey-popup-shot:hover { filter: brightness(1.06); }
/* The shared map-tooltip style turns the pointer off on every Leaflet tooltip. */
.leaflet-tooltip.map-tooltip.trek-journey-tooltip.is-openable { pointer-events: auto; cursor: pointer; }
/* The press shows on what the card holds: Leaflet positions the tooltip with its
own transform and sets its opacity inline, so the shell itself is off limits. */
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content > *,
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content > *,
.leaflet-tooltip.trek-journey-tooltip.is-openable > * { transition: opacity 120ms ease-out; }
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content:active > *,
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content:active > *,
.leaflet-tooltip.trek-journey-tooltip.is-openable:active > * { opacity: 0.6; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content,
.leaflet-tooltip.trek-journey-tooltip {
@@ -0,0 +1,98 @@
// FE-JRN-STOPS-001 to FE-JRN-STOPS-007
import { describe, expect, it } from 'vitest';
import { compareJourneyEntries, isJourneyMapEntry, journeyMapStops, type JourneyStopEntry } from './journeyMapStops';
function entry(over: Partial<JourneyStopEntry> & { id: number }): JourneyStopEntry {
return {
entry_date: '2026-10-24',
sort_order: 0,
type: 'entry',
title: 'Stop',
location_lat: 35,
location_lng: 135.7,
...over,
};
}
function numbers(entries: JourneyStopEntry[]): Record<string, number | null> {
return Object.fromEntries(journeyMapStops(entries).map(({ entry: e, stop }) => [String(e.id), stop]));
}
describe('journeyMapStops', () => {
it('FE-JRN-STOPS-001: counts through the whole journey instead of restarting each day', () => {
const entries = [
entry({ id: 1, entry_date: '2026-10-23' }),
entry({ id: 2, entry_date: '2026-10-23', sort_order: 1 }),
entry({ id: 3, entry_date: '2026-10-23', sort_order: 2 }),
entry({ id: 4, entry_date: '2026-10-24' }),
entry({ id: 5, entry_date: '2026-10-24', sort_order: 1 }),
];
expect(numbers(entries)).toEqual({ 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 });
});
it('FE-JRN-STOPS-002: numbers in reading order whatever order the entries arrive in', () => {
const entries = [
entry({ id: 9, entry_date: '2026-10-24', sort_order: 1 }),
entry({ id: 7, entry_date: '2026-10-23' }),
entry({ id: 8, entry_date: '2026-10-24', sort_order: 0 }),
];
expect(journeyMapStops(entries).map((s) => s.entry.id)).toEqual([7, 8, 9]);
expect(numbers(entries)).toEqual({ 7: 1, 8: 2, 9: 3 });
});
it('FE-JRN-STOPS-003: a sort_order tie is broken by id, as the server orders them', () => {
const entries = [entry({ id: 12 }), entry({ id: 10 }), entry({ id: 11 })];
expect(journeyMapStops(entries).map((s) => s.entry.id)).toEqual([10, 11, 12]);
expect(compareJourneyEntries(entry({ id: 10 }), entry({ id: 11 }))).toBeLessThan(0);
});
it('FE-JRN-STOPS-004: a draft keeps its pin without a number and does not move the stops after it', () => {
const entries = [
entry({ id: 1, entry_date: '2026-10-23' }),
entry({ id: 2, entry_date: '2026-10-23', sort_order: 1, is_draft: true }),
entry({ id: 3, entry_date: '2026-10-24' }),
];
expect(numbers(entries)).toEqual({ 1: 1, 2: null, 3: 2 });
expect(numbers(entries.filter((e) => !e.is_draft))).toEqual({ 1: 1, 3: 2 });
});
it('FE-JRN-STOPS-005: skeletons, gallery placeholders and entries without coordinates stay off the map', () => {
const entries = [
entry({ id: 1, type: 'skeleton' }),
entry({ id: 2, title: 'Gallery' }),
entry({ id: 3, title: '[Trip Photos]' }),
entry({ id: 4, location_lat: null, location_lng: null }),
entry({ id: 5, sort_order: 1 }),
];
expect(numbers(entries)).toEqual({ 5: 1 });
expect(entries.map(isJourneyMapEntry)).toEqual([false, false, false, false, true]);
});
it('FE-JRN-STOPS-006: the owner journal and the share link give every published stop the same number', () => {
const owner = [
entry({ id: 21, entry_date: '2026-10-23' }),
entry({ id: 22, entry_date: '2026-10-23', sort_order: 1, is_draft: true }),
entry({ id: 23, entry_date: '2026-10-23', sort_order: 2 }),
entry({ id: 24, entry_date: '2026-10-24', type: 'skeleton' }),
entry({ id: 25, entry_date: '2026-10-24', sort_order: 1 }),
];
const shared = owner.filter((e) => !e.is_draft && e.type !== 'skeleton');
const ownerNumbers = numbers(owner);
delete ownerNumbers[22];
expect(ownerNumbers).toEqual(numbers(shared));
});
it('FE-JRN-STOPS-007: leaves the caller its list untouched', () => {
const entries = [entry({ id: 2, sort_order: 1 }), entry({ id: 1 })];
journeyMapStops(entries);
expect(entries.map((e) => e.id)).toEqual([2, 1]);
});
});
@@ -0,0 +1,59 @@
/**
* Which journal entries stand on a journey's map and the number each one carries.
*
* The owner's journal and the public share link both draw the same pins, and they
* used to number them by two different rules: the share link counted through the
* whole journey (#1962) while the owner's map restarted at 1 every day, so the
* same stop read 66 on one and 6 on the other (#2886). Both views now take their
* numbers from here.
*/
export interface JourneyStopEntry {
id: number | string;
entry_date: string;
sort_order?: number | null;
title?: string | null;
type?: string | null;
is_draft?: boolean | number | null;
location_lat?: number | null;
location_lng?: number | null;
}
interface JourneyMapStop<E> {
entry: E;
/** The stop's place in the whole journey, or null for a draft the share link does not show. */
stop: number | null;
}
/** Placeholder entries the old gallery model kept in the journal; a migration removes them, older caches may still hold them. */
const GALLERY_PLACEHOLDER_TITLES = new Set(['Gallery', '[Trip Photos]']);
export function isGalleryPlaceholder(entry: Pick<JourneyStopEntry, 'title'>): boolean {
return GALLERY_PLACEHOLDER_TITLES.has(entry.title ?? '');
}
/** A skeleton is a suggestion taken from the trip, not a place anyone wrote about. */
export function isJourneyMapEntry(entry: JourneyStopEntry): boolean {
return !!entry.location_lat && !!entry.location_lng && entry.type !== 'skeleton' && !isGalleryPlaceholder(entry);
}
/** The journal's reading order, the same one the server reads the entries in. */
export function compareJourneyEntries(a: JourneyStopEntry, b: JourneyStopEntry): number {
if (a.entry_date !== b.entry_date) return a.entry_date < b.entry_date ? -1 : 1;
const bySortOrder = (a.sort_order ?? 0) - (b.sort_order ?? 0);
if (bySortOrder !== 0) return bySortOrder;
return Number(a.id) - Number(b.id);
}
/**
* The located entries in reading order, each with its stop number. A draft keeps
* its pin for the people writing the journal but takes no number, so every stop
* after it reads the same as on the share link, which leaves drafts out.
*/
export function journeyMapStops<E extends JourneyStopEntry>(entries: readonly E[]): JourneyMapStop<E>[] {
let stop = 0;
return entries
.filter(isJourneyMapEntry)
.sort(compareJourneyEntries)
.map((entry) => ({ entry, stop: entry.is_draft ? null : ++stop }));
}
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { clusterPhotos, photoMarkerHtml } from './journeyPhotoLayer'
describe('journey photo layer (#2453)', () => {
it('FE-JOURNEY-PHOTOLAYER-001: buckets by screen position, anchors on the first photo, badges a count', () => {
it('FE-JOURNEY-PHOTOLAYER-001: buckets by world position, anchors on the first photo, badges a count', () => {
const photos = [
{ id: '1', lat: 1, lng: 1, thumbUrl: '/a.jpg' },
{ id: '2', lat: 1.2, lng: 1.2, thumbUrl: '/b.jpg' },
@@ -1,4 +1,4 @@
import { clusterPoints } from './journeyMapMarks'
import { clusterPoints, type WorldPx } from './journeyMapMarks'
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
@@ -9,24 +9,23 @@ export interface MapPhoto {
}
/**
* Grid clustering in screen space, shared by the Leaflet and the GL journey map.
* Grid clustering of the photos, shared by the Leaflet and the GL journey map.
*
* The Journey maps have never had clustering, and the library the planner uses
* hangs off react-leaflet while these maps drive their engines directly.
* Bucketing by rounded pixel position is a few lines, is deterministic, and is
* enough for the job: photos of one place collapse into one thumbnail with a
* count, and pulling the map apart separates them again. `project` is the
* engine's own lat/lng to container-pixel conversion.
* Bucketing by rounded world-pixel position is a few lines, is deterministic,
* and is enough for the job: photos of one place collapse into one thumbnail
* with a count, zooming in separates them again, and panning leaves them be.
*/
export const PHOTO_CLUSTER_PX = 64
export function clusterPhotos(
photos: MapPhoto[],
project: (lat: number, lng: number) => { x: number; y: number },
toWorldPx: WorldPx,
): { lat: number; lng: number; members: MapPhoto[] }[] {
// Anchored on the first member rather than the centroid: the thumbnail shown
// is that photo's, so the pin should point where that picture was taken.
return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
return clusterPoints(photos, toWorldPx, PHOTO_CLUSTER_PX)
}
export function photoMarkerHtml(thumbUrl: string, count: number): string {
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-007: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -136,4 +136,13 @@ describe('useJourneyCarouselSync', () => {
expect(hook.result.current.activeIndex).toBe(1);
expect(map.highlightMarker).toHaveBeenCalledWith(null);
});
it('FE-JRN-CAROUSEL-008: a marker card opens its entry by id, wherever the carousel stands (#2885)', () => {
const { result, props } = setup();
act(() => result.current.openEntryById('3'));
expect(props.onOpenEntry).toHaveBeenCalledWith(ENTRIES[2]);
expect(result.current.activeIndex).toBe(0);
act(() => result.current.openEntryById('99'));
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
});
});
@@ -123,6 +123,16 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
[activeIndex, onOpenEntry, scrollCardIntoCenter, syncMapOnTap, syncMapToCard]
);
// The marker card on the map names one entry, so a tap on it opens that entry
// straight away, the same way a tap on the active carousel card does (#2885).
const openEntryById = useCallback(
(id: string) => {
const entry = entries.find((e) => String(e.id) === id);
if (entry) onOpenEntry(entry);
},
[entries, onOpenEntry]
);
// The day bar lands on the first entry of that day, which is where a reader who
// asked for "day nine" means.
const jumpToDay = useCallback(
@@ -146,6 +156,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
syncMapToCard,
handleMarkerClick,
handleCardTap,
openEntryById,
jumpToDay,
};
}
@@ -115,6 +115,8 @@ describe('useJourneyDayImport', () => {
it('FE-JRN-DAYIMPORT-004: the route goes along only when switched on, kept as previewed', async () => {
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
expect(result.current.mapTracks.length).toBeGreaterThan(0);
expect(result.current.mapTracks.every((track) => track.date === '2026-05-14')).toBe(true);
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
act(() => result.current.route.toggle());
@@ -155,8 +155,9 @@ function useDayMap(
color: dayColor,
points: segment.points,
kind: 'day_recording',
date,
})),
[route, dayColor, t]
[route, dayColor, date, t]
);
return { entries, tracks };
}
@@ -202,6 +202,8 @@ describe('useJourneyMapLayers', () => {
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
true
);
// The day travels as a date of its own, so the map can tell which stretches it covers (#2888).
expect(result.current.tracks.map((track) => track.date)).toEqual(['2026-05-14', '2026-05-14']);
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
});
@@ -163,6 +163,7 @@ export function useJourneyMapLayers({
color,
points,
kind: 'day_recording',
date: day.date,
}));
}),
[sources.recordings, colorOfDay, t, locale]
@@ -187,7 +188,7 @@ export function useJourneyMapLayers({
[places, stays, visibility, entryIdsOnMap, stayColor]
);
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.date ?? track.name)).size, [recorded]);
const controls = useMemo(
() =>
buildControls({
@@ -47,6 +47,7 @@ describe('useJourneyRecordings', () => {
expect(result.current.byDate.get('2026-05-15')?.id).toBe(4);
expect(result.current.tracks).toHaveLength(3);
expect(result.current.tracks.every((track) => track.kind === 'day_recording')).toBe(true);
expect(result.current.tracks.map((track) => track.date)).toEqual(['2026-05-14', '2026-05-14', '2026-05-15']);
// One line per piece: a gap between two pieces is never drawn over.
expect(result.current.tracks.slice(0, 2).map((track) => track.points)).toEqual([
WALK_SEGMENT.points,

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