Compare commits

...

2367 Commits

Author SHA1 Message Date
Maurice 6694a38e65 test(planner): the phone timeline breaks the drive at a place's track with tours off too 2026-10-12 22:18:50 +00:00
Maurice e643ae70ad test(costs): the phone costs cases start without a cached settlement and wait for the queued payment writes 2026-10-12 22:18:50 +00:00
Maurice 5d6dc6129c test(planner): the booking cases read stays through the repo and link a hotel made offline by its temp id 2026-10-12 22:18:50 +00:00
Maurice a9a0478c63 test(llm-parse): an unreachable ollama shows the server's reason in the addon settings, not the status line 2026-10-12 22:18:50 +00:00
Maurice 6fa51c638f test(journey): the journey page cases find the settings delete in its dialog and confirm a gallery delete first 2026-10-12 22:18:50 +00:00
Maurice c7d3dd10f2 test(mcp): update_trip moves the owner's leave days in the plan they are active in, a joined one included 2026-10-12 21:49:40 +00:00
Maurice 9adc87c002 chore(lint): lower the size, strict, hand-built service and knip baselines to what the split files hold now 2026-10-12 21:14:28 +00:00
Maurice dd8d6a0b7d chore(knip): drop the exports nothing outside their module reads 2026-10-12 21:14:28 +00:00
Maurice af3dfd0bad fix(mobile): the new invite, version, gallery and time warning labels use the theme tokens and type tiers 2026-10-12 21:14:28 +00:00
Maurice 00fca4952f test(roadtrip): the road trip suite writes stays through the real repo and seeds the searched day once 2026-10-12 21:14:28 +00:00
Maurice 645b893e92 test(planner): the tours tab cases render the page through one helper 2026-10-12 21:14:28 +00:00
Maurice 18baafe57d refactor(pdf): the remembered preview switches move to utils 2026-10-12 21:14:28 +00:00
Maurice 8832632b4e refactor(planner): each card over the map sets its own left side again, where the reading direction check counts it 2026-10-12 21:14:28 +00:00
Maurice 8f6dab30a2 refactor(planner): the weekday index of a plan day lives with the place opening state 2026-10-12 21:14:20 +00:00
Maurice 7b023c6b54 refactor(map): the road trip via helpers move to utils, so the route hook imports no component 2026-10-12 21:14:05 +00:00
Maurice 64e4c34472 test(costs): the ledger cases wait for the settlement, and the filter and former member cases get files of their own 2026-10-12 21:14:05 +00:00
Maurice b21f9d6739 refactor(costs): the summary card moves into a component of its own 2026-10-12 21:14:05 +00:00
Maurice 191b02b6de test(maps): the place pin and nominatim bias cases get suites of their own 2026-10-12 21:13:58 +00:00
Maurice 0711efcb4b refactor(maps): the country lookup moves to utils, so maps no longer reaches into atlas 2026-10-12 21:13:58 +00:00
Maurice 37b3f0f3da test(trips): the copy case for shared expenses gets a suite of its own 2026-10-12 21:13:57 +00:00
Maurice f470ae029d refactor(trips): the trip span check and the feed token strip move into trips helpers 2026-10-12 21:13:57 +00:00
Maurice 7454c8f95f test(collections): the cases for moving a place out of a list get a suite of their own 2026-10-12 21:13:57 +00:00
Maurice 4984e8f5eb refactor(collections): parsing and writing a list's links moves into collections helpers 2026-10-12 21:13:57 +00:00
Maurice 5b6f76c98a test(calendar): the calendar stop oracle reads the place duration the repository now returns 2026-10-12 21:13:57 +00:00
Maurice 5fcfe8b912 refactor(reservations): grouping endpoints and day slots moves into reservations helpers 2026-10-12 21:13:57 +00:00
Maurice 141ad0acb6 test(budget): the cases for people who left the trip get a suite of their own 2026-10-12 21:13:57 +00:00
Maurice a360e3d391 refactor(budget): the money and rate helpers move out of the budget service 2026-10-12 21:13:57 +00:00
Maurice e6aaeac67e test(mcp): the advanced budget tool cases build their controller in one place 2026-10-12 21:13:57 +00:00
Maurice 15c9503295 refactor(settings): the planner views ask trip membership which trips a user can open, and their tests seed through the factories 2026-10-12 21:13:57 +00:00
Maurice 4a6ee6819c style(client): prettier on the files this branch touched 2026-10-12 20:01:22 +00:00
Maurice 00a19679f5 style(server): prettier on the test files this branch touched 2026-10-12 20:01:22 +00:00
Maurice 3dedfe6b63 chore(lint): lower the client baseline to the warnings the app code holds now 2026-10-12 19:57:48 +00:00
Maurice dbc6cb6b94 test(client): the new cases check a missing result instead of asserting it away 2026-10-12 19:57:48 +00:00
Maurice d4c3b1f4e0 refactor(collections): all saved says why it skips a list it cannot read 2026-10-12 19:57:48 +00:00
Maurice 5eb00d5878 refactor(planner): the day sidebar reads the stop days without a memo the compiler cannot keep 2026-10-12 19:57:48 +00:00
Maurice 97833412dc refactor(collections): the invite cards take the async accept and decline as they are 2026-10-12 19:57:48 +00:00
Maurice d3be55a88f fix(bookings): the transport dialog's submit always returns a promise, as the expense button expects 2026-10-12 19:57:48 +00:00
Maurice 36e8b299f7 chore(lint): lower the server test baseline to the warnings the tests hold now 2026-10-12 19:56:44 +00:00
Maurice d8fef58799 test(server): the new suites check a missing result instead of asserting it away 2026-10-12 19:56:44 +00:00
Maurice e1bee3b562 refactor(settings): a bulk save prepares the planner views once, before the writes 2026-10-12 19:56:44 +00:00
Maurice 43677ec80b refactor(vacay): the leave days a moved trip drops go in one delete 2026-10-12 19:56:44 +00:00
Maurice 5baa1c2c69 fix(oidc): a refused email link is logged through the app logger, so it reaches trek.log 2026-10-12 19:56:44 +00:00
Maurice 8a8c3e4c64 style(i18n): prettier on the translation files this branch touched 2026-10-12 21:22:36 +02:00
Maurice 66e574fcc0 fix(llm-parse): the local model list and pull send the saved api key to an ollama behind an auth proxy
The admin screen's model list (/api/tags) and pull (/api/pull) went out
without the Authorization header that the extraction and /api/show already
send, so an Ollama behind a Bearer proxy answered 401 and the list came back
as a 502. The controller now adds the key saved with the local provider, read
server side through LlmConfigResolver.readLocalServerAccess, and only when the
requested base URL reaches the same server root as the saved one, so another
address in the form never receives it.

A 401 or 403 now says whether the key was rejected or none is saved for that
address, and the admin form shows the server's reason instead of the axios
status line. A server without /api/tags (LM Studio, vLLM) is asked for its
OpenAI-compatible /v1/models instead, and the list body is read with a size
cap and checked before use.

Refs #2649
2026-10-12 21:04:23 +02:00
Maurice accad52ed5 fix(maps): place suggestions name their town and country in the language of the search
The TREK Places service takes an optional lang now and puts localities into
it, and names its OpenStreetMap layer rows in it with the settlement they are
in. TREK never sent one, so a Japan trip in an English UI kept reading
"福岡市博多区" and the layer rows had no place at all, only the local name.

Search, autocomplete, nearby and the details of an index place now ask in the
reader's language (TREK codes through toApiLang, anything that is no language
tag left off). A layer row with a locality reads "Sumida, Japan" like an index
row, the country named from its coordinate; without one it keeps the local
name as before. The offline area download stays without a language. REST and
MCP share the service methods, so both get it.

Refs #2592
2026-10-12 20:53:08 +02:00
Maurice 2777773f7b fix(dayplan): the delete day question says its bookings keep their date
Deleting a day said its bookings stay under Bookings without a day, which
read as if their date went too. They keep their date and stay listed,
upcoming ones included; the hint now says so.

Refs #2604
2026-10-12 20:43:00 +02:00
Maurice d4f4ab3891 fix(bookings): the delete question for a booking with a linked expense says the expense is deleted too
Deleting a booking also deletes the expenses linked to it, but the
question only said the booking would be deleted. A booking with a linked
expense now says so in every place that asks: the bookings list and detail,
the transport and transit dialogs, and the phone's booking cards. The MCP
delete_reservation description names the linked expenses and the stay a
hotel booking created as well.

Refs #2604
2026-10-12 20:42:55 +02:00
Maurice 62a80f420c fix(bookings): an end time before the start time on the same day shows a warning in the booking dialog
A booking saved with 18:00 to 09:00 and no end date was stored as ending
earlier on its start day without a word. The dialog now says that the end
time lies before the start time and that an end date is needed if it ends
the next day, on desktop and on the phone. It only warns and does not block
the save, since time zones can make such times correct.

Refs #2604
2026-10-12 20:42:48 +02:00
Maurice 3b933470d3 fix(planner): a booking pinned to a stop on another day is shown at that stop only, not on two days
A booking linked to a stop has two days to go by, its own date and the
stop's. They part when the stop is moved to another day or the trip is
shifted with "keep bookings on their dates", and the plan then listed the
booking at the stop and again in the timeline of its own day. The stop now
wins wherever both are drawn: the desktop day plan and day detail, the
phone's plan tab and day sheet, and the road trip rail on both. The
booking's date is left as it is; a stop on one of the booking's own days
changes nothing, so a rental picked up at a stop still runs across its
other days.

Refs #2604
2026-10-12 20:42:30 +02:00
Maurice 93cce57306 fix(dashboard): trip counters and upcoming bookings refresh after a trip is created, copied, redated, archived or deleted
The travel stats and the upcoming bookings were only fetched when the
dashboard mounted, so "Trips" and "Days" kept their old values after a new
or duplicated trip and the sidebar still listed booking dates from before a
date change until the page was left. Every successful trip mutation now
reloads both, on desktop and mobile alike since they share the hook.

Refs #2604
2026-10-12 20:29:29 +02:00
Maurice fee41ab394 fix(collab): a note, poll, message or link that cannot be saved offline says it needs a connection
Collab, packing bags, packing templates and imports, the category a place
form creates and the place imports are not kept on the device, so offline
their writes cannot wait in the queue. They failed with a bare "Error" or the
generic save message. While TREK is offline the failure now reads "This needs
a connection. Try again once TREK is back online.", on desktop and on the
phone's Collab notes; online nothing changes.

Refs #2601
2026-10-12 20:18:04 +02:00
Maurice bdd8c08dd4 fix(planner): places put on a day, day names, day notes and trip details changed offline wait in the queue and sync once the connection is back
The day plan wrote straight to the API, so offline a place dropped on a day
snapped back with "Error assigning place", a renamed day or a new day note
failed, and a moved stop, a leg's travel mode or who joins a visit were lost.
Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the
day's name, notes text and travel mode, and the day notes (add, edit, delete,
move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation
queue: the cached day changes at once, a visit or note made offline gets a
temporary id that the edits after it wait for, and an online write held behind
an older queued one joins the queue, since none of these carry a version
token. A new order of a day that names a visit made offline is sent with its
real id, or without it when its create failed. Desktop, the phone timeline and
note sheet all write through the store, so both are covered.

The replay reads each answer for what it names: the day answer keeps the
cached notes and visits, the participants answer lands on its visit, and a
visit the server refuses with 409 (a Tour already on that day) fails instead
of waiting as a conflict no retry can solve.

The trip's name, description, reminder and the archive flag wait in the queue
too, from the planner, the phone and the dashboard. What only the server can
do says so instead of failing: new dates, a day count, a currency or a cover,
a new trip, a copy or a delete, members, guests, share and invite links, and
a new order of the days, which moves the dates of the bookings.

Refs #2601
2026-10-12 20:17:57 +02:00
Maurice 92c48fba22 fix(files): file notes, links, stars and the trash changed offline wait in the queue and sync once the connection is back
Every file write went straight to the API, so offline a star snapped back
without a word and moving a file to the trash failed with "Failed to delete
file". Notes, the place or booking of a file, a further link, the star, the
trash move, restore, delete for good and emptying the trash now go through
fileRepo and the mutation queue like the other trip data: the local copy
changes at once, and an online write to a file that still has an older one
queued joins the queue behind it, since file writes carry no version token.
The desktop file manager, its assign dialog and the phone's files tab,
menu, link and trash sheets all write through it.

The single file answers carry no ids of further links, so a replayed edit,
star or restore keeps the ones the local copy holds, and the link answer
({ success, links }) writes no row.

What only works online says so instead of failing: an upload shows that it
needs a connection, a place saved with newly picked files is stopped with a
hint, and removing a further link (its row id is known only to the server)
asks to try again once online.

Refs #2601
2026-10-12 19:32:16 +02:00
Maurice 64ceecefb3 fix(bookings): bookings, transports and stays saved offline wait in the queue and sync once the connection is back
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.

The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.

What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.

Refs #2601
2026-10-12 19:07:33 +02:00
Maurice 5dbcc4af40 fix(todo): tasks added, ticked, edited, reordered or deleted offline wait in the queue and sync once the connection is back
Every task write went straight to the API, so offline a new task failed with
"Network Error" and was lost, and a tick flipped back. Tasks now go through
todoRepo and the mutation queue like packing items: a temporary row at the
end of the list offline, the edits and the delete of it wait for its real id,
and an online write to a task that still has an older one queued joins the
queue behind it, since task writes carry no version token. A reorder offline
is queued without the tasks that have no server id yet. Desktop and phone
both write through the store, so both are covered.

Refs #2601
2026-10-12 18:24:18 +02:00
Maurice cfdbfb49c0 fix(costs): expenses and payments saved offline wait in the queue and sync once the connection is back
Every Costs write went straight to the API, so offline an expense failed with
"Unknown error" and was lost. Expenses (add, edit, delete, split, paid tick,
reorder) now go through budgetRepo and the mutation queue like places and
packing items: a temporary row offline, held behind an older queued write
online. The replay reads the expense from `item` only, so the split answer
({ members, item }) keeps the local payers and the paid answer ({ member })
never lands in the expense table. An expense linked to a place or booking
created offline is sent with the real id once that one has synced.

Payments are queued without a local row and a hint says they show once
sent; the ledger reads the balances again when the queue is through, and
offline it shows the last balances it read (new Dexie table, v10). Receipts
need an upload, so an offline save with new ones is stopped with a hint.

Refs #2601
2026-10-12 18:14:30 +02:00
Maurice a5bf5e6853 docs(help): road trip mode, the dawarich trail and the route overview are remembered on the account
Help texts in all languages and the wiki said these switches lasted for the
browser session. They now follow the account per trip.

Refs #2639
2026-10-12 17:41:57 +02:00
Maurice fbba8f96b7 fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser
The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
2026-10-12 17:41:56 +02:00
Maurice adb88142ab fix(places): the place search looks where the plan map is looking
Autocomplete and the full search took their location hint only from the
open day or the whole trip, and dropped a trip spread over several cities
as too wide. Zoomed in on one city of such a trip, the search went global.

The plan map now hands its viewport to the trip store on desktop and on
the phone, and useLocationBias tries it first. A viewport wider than the
same 60 km radius, one across the antimeridian or one from another trip
is ignored, and the day and the trip follow as before. The viewport is
cleared when the plan map leaves the screen and on a trip switch.

Refs #2620
2026-10-12 17:18:17 +02:00
Maurice 8c30d64467 docs(wiki): how an SSO login links an existing account and how an admin confirms an address
The OIDC page explains when a login by email links an account, what
email_link_blocked means and how an admin lets a blocked account link by
entering its address in the admin panel. The audit log page lists
user.email_change.
2026-10-12 17:06:59 +02:00
Maurice 9975f9f80f fix(oauth): userinfo reports email_verified only for an address the account did not set itself
The userinfo endpoint answered email_verified: true for every account, although
nobody had confirmed the address. It now follows users.email_self_asserted, the
same mark the SSO login uses before it links an account by email.
2026-10-12 17:06:58 +02:00
Maurice 3d7e8be9aa fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself
An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.

users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.

Refs #2110
2026-10-12 17:06:52 +02:00
Maurice 1b58acc78f fix(trips): deleting a guest keeps the costs adding up instead of dropping their payments
A guest's payer rows and recorded payments hung on the users row by ON DELETE
CASCADE, so deleting the guest wiped money that had really moved: expenses
they paid were left without a payer, settled payments reopened, and a custom
or receipt split lost the guest's share so the balances no longer summed to
zero.

Deleting a guest who paid for an expense or is part of a recorded payment is
now refused with a 409 (REST and the delete_trip_guest MCP tool, same text),
and the share dialog says to change the payer or delete the payment first.
Otherwise the guest's share is handed on: an equal split re-splits as before,
a custom amount goes to the others by largest remainder in the currency's
smallest step (members without an amount get one, so the expense stays a
custom split), and a receipt drops the guest from its lines and is split
again like the cost editor does. A legacy paid_by_user_id is cleared as on
account deletion. Account deletion goes through the same re-split. The
confirmation text in all languages and the wiki say what really happens.

Refs #2604
2026-10-12 16:39:17 +02:00
Maurice 2149fd5315 refactor(budget): the receipt split by line lives in shared so the server can split a receipt the way the cost editor does
calculateTicketShares in the cost editor now converts the hundredths of the
new shared splitTicketLines. Same arithmetic as before: a line nobody is
ticked for is carried by everyone on the receipt and the leftover hundredths
of a line go to the lowest user ids first.

Refs #2604
2026-10-12 16:39:07 +02:00
Maurice 92af437ff6 fix(roadtrip): the stop a booking put on its check-in day keeps the night over a rest later that day
The night went to the last visit of the hotel. On a day without hours the
booking seats its own stop first, and Days does not show that stop, so a rest
the traveller put mid-day was the last visit: it was held to the check-in and
the places behind it followed from there, the way the report describes.

The night is now the visit that ends the day, else the booking's stop, else
the last visit. A visit the traveller adds to the check-in day no longer moves
the night or its check-in. nightVisits in shared holds the rule, and the road
trip, calculate_roadtrip, the stored ride slot and the stop editor all read it.

Refs #2636
2026-10-12 16:05:17 +02:00
Maurice 36ac81322a fix(i18n): a session opened in a fresh browser starts in the browser's language
wiki/Languages.md has the account's language first, then the browser's.
The browser step only ran on the login page, so opening a trip with a
live session in a new browser (or after the site data was cleared) showed
English to anyone whose account had no language of its own.

The app now starts in the browser's language when nothing was chosen in
this browser, and keeps it when the account has none. Nothing is written
to the account; a language set in the account or picked in the app still
wins. The detection moved to its own module so the settings store can use
it at startup.

Refs #2604
2026-10-12 15:46:17 +02:00
Maurice a0331bf53b fix(dashboard): a refused archive, delete or copy says why instead of only that it failed
A member without the right to archive a trip clicked Archive and read
"Failed to archive trip", while the server had answered why. The archive,
restore, delete and copy toasts now show the server's reason, as creating
and editing a trip already did; a failure without one keeps the old line.
The buttons stay where they are.

Refs #2604
2026-10-12 15:46:17 +02:00
Maurice 125649ce1b fix(planner): cards over the map stay clear of the layer pill, and a wide tab bar shows full tab names
The base-layer pill sits in the map's bottom-left corner on purpose. The
place inspector, a tour's detail and the day panel are centred between the
sidebars and were assumed never to reach that corner, but with both
sidebars open in a 1280 window they do, and the pill covered "Remove from
Day" and Edit. These cards now keep their left edge clear of the pill: a
narrow map moves the card right of it, a wide map keeps it centred as
before. The pill itself does not move, and the phone sheets are unchanged.

The Bookings tab showed "Book" at every width, which read as cut off. The
short form now only shows where the bar is tight; from xl up the tab shows
its full name.

Refs #2604
2026-10-12 15:46:09 +02:00
Maurice cb04ebe6d2 fix(trips): the trip form drops its complaint once you edit, and the typed date hint follows your language
"Title is required" stayed on screen after the title was typed, until the
next save. Any edit in the trip form, desktop and phone, now clears the
last save's complaint; the next save checks again.

The keyboard date field always hinted DD.MM.YYYY, while it was filled and
read back in the user's own order: a Korean user saw "2026. 12. 10." under
a German hint. The hint is now the locale's own pattern (YYYY. MM. DD. in
Korean, MM/DD/YYYY in US English), and the field's name is translated.

Refs #2604
2026-10-12 15:46:02 +02:00
Maurice 35eba21128 fix(costs): row buttons, settle-up rows and share toggles say what they are about to a screen reader
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.

- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
  and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
  tooltip; the row now carries them for assistive tech too, and its Settle
  button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
  category) was read out as "..."; it now says Search in the user's
  language.
- The public link toggles on the phone share sheet and the journey share
  toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.

Refs #2604
2026-10-12 15:45:56 +02:00
Maurice dbbbd07b1c fix(costs): amounts follow their currency's decimals and format, and payments count on the local day
Several small things in the costs views, on desktop and phone alike:

- The outstanding card no longer says "You're all settled up" while a
  payment is still open in Settle up.
- The custom split shows its suggested shares as a faint placeholder in
  the currency's own form (30000 for won, not 30000.00), so it no longer
  reads like an amount already typed.
- Amount fields take only the decimals the currency has: won and yen take
  none, KWD and the other three-decimal currencies three. A switch of
  currency cuts what was already typed. 1000.555 won was saved as typed,
  listed as 1,001 and reopened as 1001.
- The expense sheet on the phone prints shares and sums with the same
  formatter as the list, with grouping and the currency's decimals.
- A payment recorded with Settle or Settle all is dated on the viewer's
  own day, and one without a date of its own is grouped by its UTC record
  time read on the local calendar. In Seoul before 09:00 it landed on the
  day before.
- "1 expenses need a payer" now uses the plural forms of each language.

Refs #2604
2026-10-12 15:04:32 +02:00
Maurice 9064dbb2f0 fix(costs): an expense made from a booking is dated on the booking day
Creating the expense from a booking opened the editor on today, and the
cost a booking import writes by itself had no date at all, so it sat under
"No date". Both now take the booking's own date, else a hotel's check-in
day, else the day the booking sits on. The server sets it for every
booking write that creates a cost, so REST, MCP, plugin RPC and the import
agree.

A start time typed on a booking with no day picked no longer borrows
today's date on desktop. Today was mostly outside the trip, the server
then moved the booking onto day 1 while the list kept today. Desktop now
does what the phone already did and keeps the time without a date.

Refs #2604
2026-10-12 15:04:22 +02:00
Maurice 33e606959c fix(budget): settle-up hands out whole won and yen, so balances add up and settle clears them
The ledger nets every currency in hundredths, and the settlement handed those
hundredths out as they were. For a currency shown without decimals the panels
then rounded each balance and flow on its own: 10,000 KRW three ways read
+6,667 / -3,333 / -3,333, two flows of 3,333 sat under "You're owed 6,667",
and Settle booked 3,333.34 won.

The balances, flows and final budget lines are now allocated by largest
remainder in whole steps of the display currency (whole won or yen, unchanged
for two and three decimal ones), so the shown set adds up to zero and the flow
amount Settle sends is the whole amount on the row. The ledger, stored rows
and manual settlements are untouched. REST and the MCP settlement summary
share the same method.

Refs #2604
2026-10-12 14:28:06 +02:00
Maurice ef150439f9 fix(vacay): leave days follow a moved trip as a whole block and never land on blocked days
Moving a trip's start shifted the owner's vacay entries one row at a time,
checking each target against rows that were about to move themselves. A
block shifted by less than its own length kept its first days behind and
pushed the rest onto the weekend, even with weekends blocked.

The shift now decides every day before writing: a day whose target is a
blocked weekend or a whole company holiday is dropped, as is one whose
target already holds leave that is not moving; a half company holiday
leaves half a day. The rest move in the direction of travel inside one
transaction. The plan is the one the owner is active in, so a member of a
fused plan gets their days moved too, and a trip edit no longer creates a
vacay plan for an owner who has none. Open vacay views are notified after
the trip update commits.

The pure vacay helpers moved to vacay.helpers.ts and the trip window tests
to their own suite, so both files stay inside their size budgets.

Refs #2604
2026-10-12 14:12:36 +02:00
Maurice 676f6233ed fix(planner): undoing a place delete brings the place back whole, or is not offered
The undo wrote a new place from nine of its fields and put it on its days as a
bare stop, so notes, website, phone, hours, picture, the visit times, day notes
and travellers were gone while the toast said it was undone. It now writes every
field the place had and plans each visit again with its times, note, end of day,
route flag, travellers and leg modes, and casts the own rating again.

The server takes a booked night, its booking and expense, linked expenses, file
links, other members' ratings and an uploaded picture down with the place, and
writing the place again brings none of that back. Such a delete is no longer
offered for undo. Undo steps that name a deleted place are dropped at the delete
instead of failing later, and adding a place, moving categories and the phone
reorder and optimize now carry their place ids for that.

Taking a stop off a day and clearing a day undo with the visit details too. The
stays are read again when a deleted place had one, here and for collaborators.

Refs #2604
2026-10-12 13:44:44 +02:00
Maurice e70fc334bd fix(costs): people who left the trip are named and keep their balance in the costs lists
Someone removed from a trip stays on the expenses they paid for or shared,
but the costs views only knew the current members. They showed up as "?"
in the expense chips and the settle-up suggestions, and the balances and
final budget lists left them out, so the listed balances no longer added
up to zero.

The names now come from what the server already sends with the expenses
and the settlement, and anyone off the trip who still carries money gets a
row after the members, on desktop and on the phone. The expense form is
unchanged.

Refs #2604
2026-10-12 13:22:32 +02:00
Maurice 64537a0224 fix(trips): a copied trip keeps every expense total without naming people who are not on it
The copy has nobody on it but the copier, yet it took every payer and
split row along. Its balances then had the copier owing people who cannot
see the trip, and the first save of such an expense dropped them and
shrank its total.

An expense anyone else pays for or shares now comes across as a planning
entry with its total and no payers, split or receipt lines; one that is
the copier's alone is copied whole. Assignment participants and the
legacy paid_by_user_id keep only the copier for the same reason.

Refs #2604
2026-10-12 13:15:22 +02:00
Maurice af66bdd910 fix(budget): saving an expense keeps the payers and shares of people who left the trip
Removing someone from a trip leaves their payer and split rows on the
expenses they were part of. Every save ran the ids the form sends back
through the roster filter, so an unchanged save dropped them, total_price
shrank to what the rest had paid and a custom split stopped adding up.

Ids already on the expense now stay allowed, as payer or as member, and
can still be taken off it on purpose. A new expense and an id that never
was on the expense are filtered as before (#1952). The paid ticks of the
split carry over a form save instead of being reset. REST, the plugin RPC
and the MCP split checks all read the same allowed set from the service.

Refs #2604
2026-10-12 13:15:17 +02:00
Maurice 106b748551 fix(maps): marker thumbs from cached place photos load under the same request limit
A place whose image_url already is the place-photo proxy URL skipped the five
slot limiter and loaded its marker picture right away. A map showing a big
list, such as "All saved" in collections, asked for every picture at once.
Those downloads now wait for a slot like the provider lookups do; the entry
itself is still there immediately.

Refs #2559
2026-10-12 12:59:45 +02:00
Maurice 8acc035efa fix(collections): all saved reads its lists four at a time and reloads once per burst of updates
The "All saved" view read every list in full at the same time, and every
collections:* websocket event started that again right away. An agent saving
places one by one over MCP sends one event per place, so an open collections
tab stacked up full reads of every list on the server.

The union now goes through the same batched loader the trip's saved-places
picker uses (four lists at a time), reloads asked for while it runs fold into
one more pass, and a list or union read that lands after the user switched to
something else is dropped instead of painting its places over the new view.
The page waits for a run of events to settle before it reloads and keeps the
selection on "All saved" like it already did on a single list. Desktop and
phone share useCollections, so both get it.

Refs #2559
2026-10-12 12:59:44 +02:00
Maurice 2f0b867685 fix(settings): the integrations tab is always there on desktop so api keys stay reachable
API keys were split off from MCP to work without any addon, but the tab
that holds them still hid unless a photo provider, MCP, AirTrail, AI
Parsing or Dawarich was enabled. On a fresh install all of those are off,
so there was no way to create a key. The desktop tab now always shows;
the addon backed sections inside it keep their own gates. The phone layout
has no API Keys section and keeps its gate.

The wiki now says where keys are created and that no addon is needed.

Refs #2561
2026-10-12 12:46:12 +02:00
Maurice cec5ff1e2e fix(maps): nominatim autocomplete and search fallback keep the location bias
On an install without the TREK Places index and without a Google key the
suggestions come from Nominatim, and autocompleteNominatim asked it without
the box the client had sent, so every suggestion was ranked globally. The
search fallback on the same install dropped the bias the same way. Both now
pass it on as a viewbox with bounded=0, which ranks results near the trip
without hiding anything further away.

Refs #2620
2026-10-12 12:41:55 +02:00
Maurice 5afdbc8633 fix(maps): name the country and drop duplicate places in index suggestions
The suggestion list under the place search showed the index's raw ISO code
("福岡市博多区, JP") and listed a place twice when the index and its
OpenStreetMap layer both carried it, which the search list after Enter
already merges. Suggestions now name the country through Intl in the
language of the search, and a layer row at the spot of an index row with a
matching name is dropped by the same rule mergeSearchResults uses, now
shared as samePlace.

Refs #2592
2026-10-12 12:38:31 +02:00
Maurice 93173b1573 fix(planner): bookings a collaborator moves on a day take their new slots in every open list
reservation:positions was on the ignored list, while the stop order the same
reorder sends was applied, so in other tabs and for other members the bookings
sat between the wrong stops until a reload. The event now writes the slots the
way the mover's own list did, for the day it names (day_id from REST, dayId
from the MCP tool) or as the global slot without one, and caches them offline.

A booking handed back by an edit or a reservation:updated event without day
slots keeps the ones it had, in the store and in the offline cache.

Refs #2631
2026-10-12 12:33:19 +02:00
Maurice 12f08e3646 fix(planner): a timed booking dropped against its time goes back alone instead of taking the stops behind the drop along
The day list draws a row with a time by that time, and an untimed stop takes the
time of the timed row before it. A transit ride dropped behind a later stop was
stored there, drawn back at its time on the next render, and every untimed stop
behind the drop went with it; the next drag then stored that shuffle. A dropped
booking or timed note now lands in the slot nearest the drop the list draws it
in, so nothing else moves and what is stored is what is shown. Rides dropped
where their time allows stay on the drop, and the rule for untimed stops behind
a booking is unchanged.

The chronology check for a timed stop reads a booking's time for the day (the
arrival on the last day of a multi-day ride) instead of its departure, and the
three copies of the remove and insert step share one helper.

Refs #2631
2026-10-12 12:33:12 +02:00
Maurice eaab7dc4e4 fix(reservations): a booking read on its own carries its day slots, so an edit no longer drops them
The create and update answers, the reservation:updated broadcast and the MCP
reservation tools all hand back the single booking read, and that read never
loaded reservation_day_positions. The day list puts a booking moved on a day by
that slot before its time, and the client replaces the booking with the answer,
so a booking moved on a day jumped back to its time after every edit until the
next reload. The read now attaches day_positions in the shape the trip list
already sends.

Refs #2631
2026-10-12 12:32:24 +02:00
Maurice c2dd66986c fix(maps): the day route leaves an imported gpx track to the track and carries on from its end
A GPX or KML import stores the track's first point as the place's position,
so the day route treated it as a single stop there and routed from the
track start to the next stop, a second road running alongside the track.
Any assigned place with a usable track now gets the gap a Tour already had:
the route runs up to the track start and continues from the track end, on
the desktop map and day list, the mobile timeline, the trip overview and the
PDF map, which now draws the track as a line. A place without a usable track
stays a plain stop and never shows as an invalid Tour.

Refs #2600
2026-10-12 11:43:50 +02:00
Maurice 3dbe76945d fix(roadtrip): the day view drives the road picked in the road trip
A chosen alternative or a hand-bent drive is stored as vias, and only the
road trip rail routed through them. The day route on the map, its
connectors in the day list and on the phone, and the overview of all days
asked the router for the bare stops, so they drew and measured the default
road again.

They now thread the same vias between two stops that follow each other in
the stored list, with the anchor counted the way the rail counts it, and
fold the answer back to one leg per stop pair. Only while the road trip
addon is on; avoid settings and the routing engine stay with the rail.

Refs #2580
2026-10-12 11:32:15 +02:00
Maurice 565333a90c fix(roadtrip): a hotel visited again on its check-in day is the night only at its last visit
A stop counted as the booked night whenever a stay began at its place on its
day, so a hotel on its check-in day twice (the booking's own stop and a rest
the traveller planned there) was read as two nights. Both were held to the
check-in: the rest waited for the room or was set back to it, and with a check-in
far from its arrival the rest and every stop behind it moved onto the next day's
card.

oneNightPerStay in shared keeps the night and its check-in on the last visit of
the place and turns the earlier ones into ordinary stops, still a hotel. The
browser and calculate_roadtrip both run it, the stored ride slot times only that
row by the check-in, and opening an earlier visit in road trip mode opens the
stop rather than the night.

Refs #2636
2026-10-12 11:05:41 +02:00
Maurice e3bf18f9a6 fix(collections): a list invite can be accepted where its notification leads, on phone and desktop
The invite notification links to /collections/:id, a list the invitee
cannot read until they accept. The phone screen had no invite UI at all
and showed "Start your first collection", and on desktop the rail with the
invites is hidden below 1080px while the empty state had no way to open it.

useCollections now recognises a route id that is a pending invite, keeps
it out of the route sync and puts it first in the invites it hands out.
The phone screen shows the pending invites as cards above the lists, and
the desktop body shows them when the person has no lists of their own,
or the one invite the route points at. Both reuse the existing invite
strings. A route id that is neither a list nor an invite (declined,
withdrawn, deleted) now drops back to /collections once the lists are in,
instead of leaving the page on a list it cannot show.

Refs #2645
2026-10-12 10:44:57 +02:00
Maurice c94d275cfd fix(journey): gallery photos can be deleted on touch screens and ask before they go
The delete on a gallery tile only showed on mouse hover, so a phone or
tablet never saw it, and the phone screen had no delete at all. Where it
was reachable it deleted at once, although the server removes the photo
for good and takes it out of every entry that uses it.

The desktop tile now shows its delete on hover, on keyboard focus and
always where there is no hover, with a larger target there and an aria
label; viewers no longer get the button. The phone gallery gets the same
corner button for editors. Both ask through ConfirmDialog first, saying
that the photo also leaves the entries it is used in, and both delete
through the store action. The entry editor's photo controls follow the
same hover rule.

Refs #2593
2026-10-12 10:32:32 +02:00
Maurice 90352b8e3a fix(journey): draw the dashed line between entries on fullscreen leaflet maps too
The Leaflet journey map only drew the dashed route between entries when
fullScreen was off. Since the desktop sidebar and the mobile map plus
carousel view both pass fullScreen, the line was missing on the public
journey page (desktop and phone) and in the logged in views with the
Leaflet provider, while the GL renderer always drew it. fullScreen now
only enables wheel zoom, matching JourneyMapGL where it only sets the pitch.

Refs #2606
2026-10-12 10:19:39 +02:00
Maurice 4ee5e3fa96 fix(bookings): booking cards always show the calendar date of their linked day
Phone booking and transport cards printed only the title of the linked day,
so a long title cut off in the field left no date at all. They now print the
date beside the title like the desktop card, as a span that never shrinks
while the title is truncated, and a day range takes the full width with the
time under it. The desktop card gets the same non-shrinking date.

A day without a date of its own (a trip planned without dates) now borrows
the booking's start or end date, on the desktop cards, the list, the detail
dialog, the shared page and the phone alike.

Refs #2590
2026-10-12 10:16:01 +02:00
Maurice 7cb96c3d74 fix(mobile): show the trek version as a footer in the mobile account menu
The avatar menu of the mobile dashboard lost the version pill that the
desktop account menu shows under sign out. It now renders the same
appVersion from the auth store as a small footer, with the wordmark
following the active theme. Discord, help and the prerelease badge stay
desktop only.

Refs #2581
2026-10-12 10:08:08 +02:00
Maurice 8de40db40e fix(calendar): a timed stop without an end time ends after the place duration
A stop with a start time but no end time went out as a VEVENT without
DTEND. Calendar clients fill in their own default length for that, mostly
an hour, so a 20 minute stop showed up as an hour. The event now ends at
start plus the place's duration_minutes when that is set and above zero,
carried over midnight like the stay markers. An explicit end time still
wins, so start and end under an hour stay as they were. The trip download,
both calendar feeds and the MCP export share this builder.

Refs #2641
2026-10-12 10:05:24 +02:00
Maurice 36b8baf259 fix(atlas): removing the last region keeps a country the user marked visited itself
Marking a region wrote its country with source 'manual', the same as ticking
the country off by hand, and removing the last region then took the country
away whatever its origin, Dawarich confirmations included. A country that
only comes in through a region is now stored as 'region', marking it visited
afterwards turns it into the user's own mark, and the cascade in unmarkRegion
only runs for a country without such a mark. Readers still see 'manual' for
those rows, so the country detail, the MCP resource and the plugin RPC answer
as before. Rows written before this change stay 'manual' and keep their
country.

REST, MCP and plugin RPC share the service. The atlas popup on desktop and
phone now asks the server whether the country is still marked before it
drops it from the map instead of guessing.

Refs #2597
2026-10-12 09:55:52 +02:00
Maurice b3fe593a1e fix(share): translate cost categories on the public trip page
The shared Costs tab grouped expenses by the stored category and printed it
as is, so the fixed keys ("accommodation", "flights") showed in English
whatever language the reader picked. It now groups through catMeta and
labels each group with costs.cat.*, the way the Costs tab does, so old
free-text labels also join their key instead of forming groups of their own.

Refs #2596
2026-10-12 09:38:19 +02:00
Maurice 11ede4fbef fix(journey): emptying the location field in the entry editor removes the location
The field showed the stored place as soon as the search query was empty, so
deleting the last character snapped it back and the location could never be
removed. Clearing the field now empties the place and its coordinates, closes
the suggestions and drops any search answer that comes back afterwards. Typed
text without a picked result keeps the stored location as before. The hook is
shared, so the desktop dialog and the phone sheet both get the fix.

Refs #2615
2026-10-12 09:33:00 +02:00
Maurice 1ea72dca97 fix(config): trim whitespace around NODE_ENV, LOG_LEVEL and DEFAULT_LANGUAGE once at boot
NODE_ENV="production " passed the boot check but matched none of the
exact production comparisons, so the web UI was never served and every
page answered {"error":"Cannot GET /"}, while the banner showed a clean
"production". The boot import now trims these three variables in
process.env before anything else reads them, so Express, the plugin
children and readEnv() agree, and reports each change as a process
warning. Casing stays as it was. A padded "test" is only reported, so an
instance cannot switch to an in-memory database after an update. The
banner prints NODE_ENV as set instead of lowercasing it.

Refs #2565
2026-10-12 09:27:19 +02:00
Maurice 7397e33c31 fix(maps): resolve google place links to the place pin instead of the camera
A Google place URL carries two points: @lat,lng is the centre of the map
view, the !3d!4d data param is the place itself. extractCoords took the
camera first, so a pasted share link put the place around 170 m off and
reverse geocoded the wrong street. The data param is now read before @;
the order of @ and ?q=/?ll= is unchanged. REST and the resolve_maps_url
MCP tool share the resolver, so both pick this up.

Refs #2635
2026-10-12 09:21:56 +02:00
Maurice aa1bb41cf5 fix(collections): moving a place out of a list needs the same role as deleting it
Moving a saved place to another list takes it out of the source list, but
updatePlace only asked for edit rights there, so an editor could empty a
shared list they may not delete from. The source now takes owner or admin,
like a delete, and the target still takes edit rights. REST and the
update_collection_place tool share the service path. The Move to list
action on desktop and phone follows the delete permission.
2026-10-12 09:17:08 +02:00
Maurice 6c8321cd63 docs(readme): swap the tour video for the new promo film 2026-10-11 22:19:32 +02:00
Maurice 1157805146 build(docker): build gosu with Go 1.26
Go 1.25.14 carries ten stdlib CVEs that Docker Scout fails the image on;
1.26.9 fixes them.
2026-10-11 21:33:32 +02:00
Maurice aeaf3693ad fix(mascot): typecheck the clip metadata and keep the client ratchets green
The tuple cast on clips.meta.json failed tsc. The clip lookup and the 3D
opt-out move into mascotClips.ts so the component file only exports the
component, the crop offset is allowed as a deliberate physical side, and
MDancingTrek leaves the format baseline.
2026-10-11 21:33:31 +02:00
Maurice ee1b6a837f feat(mascot): 3D mascot as transparent 60 fps clips with a click animation per scene
Every empty-state scene of the TREK mascot now plays a pre-rendered 3D clip
instead of the SVG: a seamless loop per scene and theme plus a one-shot
animation on click that cross-fades in and out.

The clips are plain H.264 MP4 with stacked alpha (colour on top, the mask
below) that a small WebGL shader joins into a transparent image, so the same
file plays in every browser including iOS, decoded in hardware. The videos stay
2 px stand-ins and are drawn onto a canvas, which keeps browser video controls
off the mascot. Each clip is cropped to the area its scene uses and placed back
inside the 88:96 box, so every scene keeps the same scale.

MDancingTrek picks the clip when one exists for the scene and face and falls
back to the SVG under reduced motion, without WebGL, when autoplay is refused,
in tests, or with localStorage trek-mascot-3d set to 0.
2026-10-11 15:08:48 +02:00
Maurice 4a36a60f5a test(roadtrip): a ferry leg draws no car on its band, while the day head keeps its drive pill's car 2026-10-11 15:06:59 +02:00
Maurice 4b1957f1b4 chore(i18n): drop roadtrip.ride.duration, which nothing reads since the ride card lost its duration pill 2026-10-11 14:33:10 +02:00
Maurice c6f45b9be6 refactor(planner): a day head's pill row moves into a module of its own
The stays, hire cars and plugin label under a day's name now live in DayHeadPills, so DayPlanSidebar shrinks back under its size baseline. The stay fields are read through their types instead of an any cast, and the size, eslint and theme-disable baselines are lowered to the new counts.
2026-10-11 14:22:46 +02:00
Maurice 32606d20a6 fix(roadtrip): the rail passes the strict and lint gates, and its tests describe the new markup
The day head falls back to metric when no distance unit is set, VISIT_BOX is no longer exported, the day head is split into smaller parts and the booking icon is created once. The component tests now check the pill badges, the day tile and the ride cards instead of the old boxes, and both baselines are lowered to the new counts.
2026-10-11 14:01:18 +02:00
Maurice e8fd41b9b4 feat(roadtrip): the Google route import and the track dialog use the shared dialog frame, and Search and Manual match the day plan's buttons
Both dialogs now open in the same frame as the stay and stop dialogs: an icon tile and title in the head, the content in a card, and the actions in the footer. Closing stays blocked while an import runs. In the right column, Search and Manual take the size, text and icon of the day plan's toolbar buttons.
2026-10-11 14:01:18 +02:00
Maurice 0c554e319b fix(planner): a day head keeps its stays on one line, each name cut by the same share, under the name and the tools alike 2026-10-11 02:42:21 +02:00
Maurice 856f5b5b08 feat(roadtrip): the rail and its dialogs in the planner's card language
Day heads take the day plan's layout: the number on a raised tile with the date
under it, the name beside it and its facts as pills that never wrap but give way
in steps. Rides, terminals and hire desks are cards in their type's colour with
their icon on the line and their times in the arrival column; a stop's bookings
sit on its own badge row. Every badge on the rail is one pill shape, the drive
bands are round, and the stay, stop and driving settings dialogs use the shared
dialog frame.
2026-10-11 02:36:58 +02:00
Maurice 3ff88062f1 fix(planner): pills on a white row keep a hairline, share one height, and a transport reads its time before its phase 2026-10-11 02:36:58 +02:00
Maurice cd94b010ed build(docker): the server build gets a 4 GB heap, so the image builds on hosts with less memory 2026-10-11 00:44:14 +02:00
Maurice 7c56c74077 test(server): cover the env mapping of every registered setting and the packing write paths the ratchet counts 2026-10-10 23:37:19 +02:00
Maurice 0b8ddd7dcb test(server): day order tests follow the dateless-day rule and the OIDC hop check reads the formatted call
DAY-SVC-048 and DAY-DEL-011 pinned the old reorder, which handed a day
without a date the first slot's date; they now expect it to keep none and
the day it passes to keep its own. OIDC-SSRF-003 reads oidc.service.ts as
text and missed the token call once Prettier put a trailing comma after its
hop budget of 0; the pattern takes the comma.
2026-10-10 22:53:59 +02:00
Maurice cc94bc6953 refactor(planner): the day header's stay order lives in its own module and its tests in their own file
The day badge fix grew DayPlanSidebar.tsx and its test past their size
baselines. The check-out, through, check-in order of the header's stays is
now dayHeadStays() with a test of its own, and the day header tests sit in
DayPlanSidebar.dayHead.test.tsx; both files drop below their old entries.
2026-10-10 22:18:36 +02:00
Maurice 10ffc24395 chore: drop the Place import the removed helper type left unused and lower the client test lint baseline 2026-10-10 22:04:10 +02:00
Maurice 3fdae47041 chore: drop the exports the reworked server modules left without an importer
knip found them once the server lanes and the client ratchets met: re-exports
and helpers that moved into place-import, nest-rpc and auth-core but kept an
export nothing reads, the demo-denied helper the central MCP gate replaced,
and two settings keys only the selector uses. @OptionalAuth stays exported
as the documented opt-out and is marked @public. The baselines for knip and
the unused en keys drop to the merged tree.
2026-10-10 21:52:09 +02:00
Maurice b9a8bfc3e7 refactor(budget): the cost preview splits through the shared cent split the settlement uses 2026-10-10 21:43:08 +02:00
Maurice d2e044869a fix(days): a day without a date keeps no date when it is moved, so it can go to the front of a dated trip
Reordering handed out the dates by position, so moving a day added without a
date to the front gave it the first date and took the date of the last dated
day. The undated row stayed at the end and the move looked like it did
nothing, by drag and by the arrows alike. The dates now go to the dated days
only, in their new order; the rule lives in shared so the server, the
planner's optimistic list and the MCP tool apply the same one.
2026-10-10 21:34:51 +02:00
Maurice d496dd5d30 fix(planner): a day without a stay centres its name on the day badge 2026-10-10 21:33:38 +02:00
Maurice b31c2ff1d2 ci(client): hold the en keys nothing reaches to a baseline that only shrinks
lint:i18n-keys only listed unused en keys as information, and it looked for
references in client/src alone, so every key the server sends (system notice
and notification channel keys in its registries) read as dead. The reference
search now also reads server/src and plugin-sdk/src; those references only
mark keys as reached and are not held to the client's key rules. The unused
keys per en domain file are counted against scripts/i18n-unused-baseline.json,
which only shrinks and fails on an entry above its count until --update
lowers it. Today's 841 unused keys in 44 files are frozen, none deleted.
2026-10-10 21:33:29 +02:00
Maurice c83fa66c67 ci: hold unused files, exports and dependencies to a knip baseline that only shrinks
Nothing looked for dead code: no knip, depcheck or ts-prune, noUnusedLocals
off. knip.jsonc configures knip over the client, server and shared workspaces
with their real entry points (main.tsx, index.ts and bootstrap, the MikroORM
config, the forked plugin host and its SDK surface, migrations and seeders
found by glob, the shared build entries with one per locale, the scripts,
the tests and the Playwright launcher). lint:knip (scripts/ci/knip-ratchet.mjs)
counts its findings per category and file against scripts/ci/knip-baseline.json,
which only shrinks and is built on the client's ratchet helpers. Today's 427
findings are frozen, among them unused server dependencies (nodemon,
@types/uuid, eslint-plugin-prettier, @mikro-orm/cli), the unused root unrun,
and imports of packages no package.json lists (leaflet.markercluster, geojson).
False positives are fixed in knip.jsonc with the reason. knip is a root
devDependency; the check runs in server-quality, the job that installs every
workspace, and knip.jsonc joins the code area of the change classifier.
2026-10-10 21:33:29 +02:00
Maurice 83d807781e ci(client): hold strict null check errors per file to a baseline that only shrinks
The client compiles with strict: false, so nullable contract fields arrive as
if always present and implicit anys stay invisible. tsconfig.strict.json is the
same program with strictNullChecks and noImplicitAny, and lint:strict
(scripts/strict-lint.mjs) counts its errors per file against
scripts/strict-baseline.json: a file may not gain one, a new file has to be
clean, and an entry above its count fails until --update lowers it. Nothing is
converted here; today's 534 errors in 165 files are frozen. allowJs stays: the
tests import the .mjs scripts and read their JSDoc types through it.
The todo MSW handler maps a cleared priority to its default so the test
helper written in this branch starts at zero. The check runs after the
typecheck in client-quality.
2026-10-10 21:32:59 +02:00
Maurice c266a5e1a7 chore(client): freeze typed promise, hook, size, store and global rules in the ESLint baseline
The client lint ran untyped with two react-hooks rules, so nothing saw a
floating promise, an async handler where a void one is expected, a whole-store
Zustand subscription or a window event bus, all of which client/CLAUDE.md
forbids. eslint.config.mjs now lints with the project service and adds, as
warnings: no-floating-promises, no-misused-promises (void returns in JSX
attributes included), the rest of react-hooks v7's recommended set,
complexity 20, max-depth 4, max-params 5 and max-lines-per-function (120 in a
.ts function, 300 in a .tsx one, whose body also holds its markup),
no-console and no-non-null-assertion. Three local rules in
scripts/lib/eslint-rules.mjs cover what no stock rule sees: an empty
.catch() callback, use*Store() without a selector (the message points to
useShallow, since an object selector without it loops on Zustand 5), and
window.__* globals and window.dispatchEvent with an event of our own.
scripts/eslint-baseline.json takes today's count of each new rule, so the
existing code is frozen, not changed, and each new instance fails
lint:warnings. The CI step gets the 8 GB heap typed linting needs.
2026-10-10 21:32:23 +02:00
Maurice 242756708e ci(client): run theme:lint as a per-file ratchet with the missing token rules
theme:lint only reported a total and exited 0, skipped whole files by name and
never ran in CI. It now counts per file under src/ against
scripts/theme-baseline.json, which only shrinks, like lint:rtl: the colour
literals, arbitrary colour classes and numeric font sizes it already saw, plus
palette classes (bg-gray-100, text-indigo-600, bg-white), raw text sizes
(text-sm, text-[13px]) instead of the type tiers, z-index literals above z-50
instead of a step of the layering scale, and reads of the dark_mode setting.
The file exemptions are gone; the map, PDF and brand files carry their count in
the baseline instead. theme-lint-disable markers stay valid in a comment and
are counted against scripts/theme-disable-baseline.json, like rtl-lint-disable
in lint:rtl; a new one raises its file's entry by hand, where review sees it.
The comment handling lint:rtl already had moves into scripts/lib/source.mjs so
both read source the same way.
The check runs in the client-quality job.
2026-10-10 21:32:05 +02:00
Maurice f51a64017d refactor(client): type both trip map renderers against one MapViewProps contract
MapViewAuto took props: any and the Leaflet MapView destructured any, so a prop
one renderer learned and the other never heard of went unnoticed.
components/Map/mapViewContract.ts holds MapViewProps, derived from the GL
renderer's props and what the callers pass; MapView and the GL bindings in
glLazy declare satisfies ComponentType against it, and the GL renderer extends
it with its engine. The file imports types only, so check:gl-split is not
affected. Types only, no runtime change.

The types show two known gaps, recorded in the contract rather than papered
over: onMapReady is called by the GL renderer only, and callers pass pins
without trip_id (assignment and collection places) while both renderers type
their pins as Place; MapViewAuto bridges the latter with one commented cast.
2026-10-10 21:31:34 +02:00
Maurice 5fcb307270 test(client): hold the default MSW handlers to the @trek/shared contracts
contractHandler() parses the request body against the shared request schema,
answering 400 like the server's Zod pipe on a body it would refuse, and the
mock's answer against the response schema, failing on a factory that drifted.
The trips, budget, places, todo, tags and categories, day notes, packing,
reservations and assignments handlers and the vacay plan and colour updates use
it wherever shared has a schema; the offline bundle and the other vacay routes
have none yet and stay plain.
The typed bodies showed where the old handlers echoed request shapes back as
stored rows: payers and members are no longer echoed into a budget item, and
checked and is_private come back as 0/1 the way the server stores them.
2026-10-10 21:31:34 +02:00
Maurice da280f0d2f test(client): reset stores from a registry that a test holds complete
resetAllStores read a hand list of seven stores, and a new store simply never
joined it. tests/helpers/storeRegistry.ts now names every Zustand store in
src/ as either reset or left alone with the reason, and a test fails on a
store that is in neither list or on an entry whose store is gone. The set of
stores that are reset is unchanged.
2026-10-10 21:31:34 +02:00
Maurice 2b86d71d51 test(client): drop the mocks of the deleted server database service
server/src/nest/database/database.service no longer exists, so the five
vi.mock calls that guarded against importing it guarded nothing.
2026-10-10 21:31:33 +02:00
Maurice e971c41d1a chore(client): delete the old budget panel components that only their own tests still imported
BudgetPanel, its summary, category table, inline edit cell, add row, per person
line, pie chart, ring avatar and the useBudgetPanel hook were replaced by the
costs panel and nothing outside their own tests imports them any more.
BudgetPanel.constants, BudgetPanel.helpers and BudgetPanelMemberChips stay in
use. The rtl, format, offline and eslint baselines drop the deleted files.
2026-10-10 21:31:33 +02:00
Maurice 0032a24236 chore: git blame skips the prettier reformat of server and shared
.git-blame-ignore-revs lists the formatting commit, so GitHub's blame view
and a local git blame with blame.ignoreRevsFile set attribute each line to
the change that last meant something.
2026-10-10 21:23:54 +02:00
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00
Maurice f4e518b2df build(server): Prettier leaves the generated entity files to their generator
src/db/entities/ is written by scripts/generate-entities.ts, and
check:entities holds every file byte for byte to its output, which puts the
MikroORM import first where Prettier's import sorting puts it last.
Formatting them would fail that gate, and npm run format would do it on any
run. lint:format no longer scans the directory (its 133 entries leave the
format baseline) and a .prettierignore keeps the format script off it.
2026-10-10 21:21:16 +02:00
Maurice 947d51cef7 build(server): a test-sql-allow marker covers the first call of the statement Prettier broke up below it
Following the method chain was not enough: Prettier also wraps a cast read
in parentheses (const n = ( above db.prepare(), puts a for head above its
body and nests the call in an argument list, each leaving it more than one
line below its marker. When the line under a marker holds no call, the
marker now excuses the first call further down in that statement, up to
the line ending it with a semicolon, never past a blank line and at most
eight lines down. Every file counts the same as before on today's tree and
on the formatted one.
2026-10-10 21:05:16 +02:00
Maurice 97f4784790 refactor(server): keep three suppressed lines whole so Prettier cannot move them past their directive
Prettier wraps the emoji regex onto the line after its declaration and
spreads two any-cast lines in the plugin tests over several lines, which
would leave the casts outside the eslint-disable-next-line above them. The
regex directive now sits on the line right before the literal, the dev-link
controller takes never instead of any for the collaborators it ignores, and
the supervisor test reaches the private kill/onExit through one cast.
2026-10-10 20:52:57 +02:00
Maurice 5c198f2848 build(server): lint:mcp-zod counts a z. call that a line break splits from its dot
Prettier breaks a long schema into z on one line and .object( on the next,
which the count read as nothing: formatting the tool files would have taken
134 hand-written fields out of the tally. Whitespace before the dot counts
now. Four files already held such a split call, so their entries rise to
what they really hold (atlas 23, doc-sync 1, help 4, transit 16).
2026-10-10 20:49:22 +02:00
Maurice 0903dcd5ab build(server): a test-sql-allow marker covers the statement below it when Prettier breaks it into a chain
Prettier splits a long db.prepare('...').all() into db on one line and
.prepare( on the next, which put the call two lines below its marker and
made every parity oracle in the repository tests count as a raw statement.
The marker now also covers the method-chain lines that continue the
statement right below it, up to the first line that does not start with a
dot.
2026-10-10 20:48:33 +02:00
Maurice 517fa21e2f build: prettier leaves side-effect imports where they are
The sort-imports plugin moved bare imports along with the rest, which on
src/index.ts would have put reflect-metadata after the modules that need it
and the env validation ahead of dotenv. With importOrderSideEffects off, a
side-effect import stays in place and the imports between two of them are
sorted on their own. The three test files that were already formatted gain
the blank line the new grouping writes after reflect-metadata.
2026-10-10 20:30:16 +02:00
Maurice 939b13859d test(server): the booted test app stubs the Nominatim reverse lookup instead of rebuilding the photo capture service
bootTestApp replaced JourneyPhotoCaptureService with a factory calling its constructor positionally, only to hand it an offline geocoder, so a dependency the service gained broke the boot of every MCP suite. The container now builds the service itself and the OsmClient's reverse lookup answers that nothing is known, which also keeps every other reverse lookup a booted suite reaches inside the process.
2026-10-10 20:16:14 +02:00
Maurice ac918d362f build(server): the test-mock and hand-built-service ratchets read code, not comments, and see the import() and alias forms
lint:test-mocks missed vi.mock(import('../src/config'), ...), the typed form Vitest takes, and counted mocks quoted in doc comments, which left slack a real mock could take. lint:test-new-service missed a service built under an import alias or as new (Name)(...). Both now strip comments first and recognise those forms; the baselines lose the entries that were only comments.
2026-10-10 20:16:14 +02:00
Maurice ef68c62d80 build(server): the response-contract exempt marker only excuses a handler that writes through @Res()
A response-contract-exempt comment counted any handler as covered, whatever it returned, so one comment line let a JSON handler past the ratchet. The marker now counts only on a handler taking @Res() or @Next(). The five vacay holiday proxies return the provider's JSON, so they lose their markers and are counted again until they declare a schema.
2026-10-10 20:16:14 +02:00
Maurice 8187a423e2 fix(admin): an invite for a trip that does not exist answers 404 instead of an empty 201
createInvite returned { error: 'Trip not found', status: 404 } and the admin controller never looked at it: POST /api/admin/invites with an unknown trip_id answered 201 with an empty body and wrote an admin.invite_create audit row for resource 'undefined'. The service throws the DomainError now, so the route answers 404 { error: 'Trip not found' } and writes neither the invite nor the audit row.
2026-10-10 20:16:14 +02:00
Maurice 8fb888c8a3 build(server): lint:service-http counts envelopes in every domain file and every spelling of a branch on one
The ratchet only read *.service.ts for status literals and one branch shape in controllers and tools, so an envelope in a helper, impl or provider file, a branch written as result.error === 'not_found', and RPC handlers all went uncounted. It now counts the literals in every file under src/nest outside the HTTP layer, and comparisons, negations, optional chains and ternaries on a returned error in controllers, tools and RPC handlers. The baseline takes what the wider count finds.
2026-10-10 20:16:13 +02:00
Maurice ebd8a4f8c9 build(server): lint:boundaries counts the RPC and MCP layers as domains and sees getRepository calls
src/nest-rpc, src/nest-mcp and src/mcp import domains and are imported by them, but the domain map only knew src/nest, so their edges dropped out of domainCycles and their repository injections out of foreignRepositories. Each layer now counts as one domain. The repository rule reads the syntax tree for @InjectRepository(X) and for getRepository(X) on an EntityManager, so a comment no longer counts and a foreign read through the EntityManager no longer passes. The baseline takes the cycles and the repository reaches the wider view finds; settings -> Users goes, it was a comment.
2026-10-10 20:16:13 +02:00
Maurice 0f6ae8b928 refactor(vacay): the invite and share tools ask a kernel user lookup instead of injecting UsersRepository
VacayMcp read the caller's username and email straight from UsersRepository, a table the auth domain owns. UserLookupService in auth-core answers exactly that, so vacay neither imports AuthModule nor injects a foreign repository, and its foreignRepositories entry goes.
2026-10-10 20:16:13 +02:00
Maurice bb6eb1ab89 fix(mcp): the six write tools that never had a demo check stay open to demo accounts under the central gate
sync_trip_documents and the five manual school-holiday tools carried no demo check of their own before the gate moved into the registry, so a demo account could call them. The gate now lets exactly those through, and a test pins the list. upload_trip_file keeps the gate's order: a demo account is refused before its trip access is checked, where the tool used to answer a trip it cannot see with the no-access result first.
2026-10-10 20:16:13 +02:00
Maurice e7e26bf2e6 chore(shared): the format lint script imports URL instead of relying on a global 2026-10-10 20:16:12 +02:00
Maurice 4427c435a8 ci(server): module mocks, hand-built services and test file length in tests/ may only shrink
lint:test-mocks counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db per file under tests/, lint:test-new-service counts new XService(/new XRepository( there with every class whose constructor takes nothing allowed (derived from src/ and tests/helpers/), and lint:size holds files under tests/ to 2000 lines against their own baseline. Both counters share scripts/lib/count-ratchet.mjs, which gains a prepare hook for a check that reads the tree first. The three run as steps of the existing server lint job; the CLAUDE.md files and the module guide describe the test helpers they point to.
2026-10-10 20:16:12 +02:00
Maurice 9bb5854dbb test(server): the suites left in tests/unit/services move next to the code they cover
src/services is gone, so the folder named a layer that no longer exists. The airtrail suites go to tests/unit/nest/integrations, the crypto, cookie, avatar, password policy, timezone and conflict helpers to tests/unit/nest/common, the kitinerary mapper to booking-import, the photo media suite to photos, the atlas bundle guard to tests/unit/nest and the two migration suites to tests/unit/db. Only the relative import paths change, and the format baseline follows the new paths.
2026-10-10 20:16:12 +02:00
Maurice 6ce15157a1 test(server): MCP suites run on the booted container and unit suites take services from their module
createTestModule (tests/helpers/test-module.ts) builds a Nest testing module of the domain modules under test over the suite's test db, with a FakeRealtimeService and provider overrides for the collaborators a suite controls. bootTestApp (tests/helpers/test-app.ts) boots AppModule once per file, with recording realtime, fixture storage and an offline geocoder, and the MCP harness takes McpRegistryService from it inside a request context instead of hand-wiring roughly eighty services. The days, day removal and day notes service suites take their subjects from DaysModule and DayNotesModule.
2026-10-10 20:16:11 +02:00
Maurice fa0edfd714 test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
2026-10-10 20:16:10 +02:00
Maurice 00cbc5aae3 test(server): src/config takes fixed test values from the setup file, so suites stop mocking it
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
2026-10-10 20:16:10 +02:00
Maurice 93c2bf0740 test(places): the import and image error cases stub the DomainError the service throws now 2026-10-10 20:16:09 +02:00
Maurice 1f78438879 chore(shared): journey.schema.ts leaves the format baseline now that it is formatted 2026-10-10 20:16:09 +02:00
Maurice 848655d593 chore(plugins): the host plugin API file holds its size baseline again
Tightens the two comments the rawBodyBase64 field and the type parity note
added to runtime/plugin-sdk.ts, lowers its lint:size entry, and points the
shared guide at the client files it names by their full path.
2026-10-10 20:16:09 +02:00
Maurice e21f2d97b6 feat(contracts): vacay and journey routes declare their response schemas
The vacay plan, calendars, years, entries, stats and shares, and the
journey list, create, tracks, stats, share link and every success answer
get response schemas in shared and a @ResponseContract on their handler.
The holiday proxies pass the provider's answer through and are marked
exempt. The journey e2e stubs return whole rows, as the service does.
2026-10-10 20:16:08 +02:00
Maurice 8e01dbb92f feat(contracts): todo, settings, collab and file routes declare their response schemas
shared gains the response schemas these routes answer with (rows in their
storage spelling, as the routes have always sent them) plus the common
success and empty answers, all exported for the client to type its calls
with. Every JSON handler in the four controllers carries its
@ResponseContract; the file download, which streams, is marked exempt. The
files e2e fixture table gains the note_id column the real one has.
2026-10-10 20:16:08 +02:00
Maurice 87071bf738 feat(server): @ResponseContract checks a handler's responses against its shared schema in tests
The decorator records the @trek/shared response schema a route answers
with. Under NODE_ENV=test it adds an interceptor that parses every response
and fails the request with a logged 500 naming the handler and the issues,
so the e2e and integration suites catch a response that drifted from its
contract; in production nothing is added to the request path and the body
is never rewritten. lint:response-contracts holds the handlers still without
one to a per-file baseline that only shrinks, and runs in server-quality.
2026-10-10 20:16:08 +02:00
Maurice 81ec7dd28d fix(plugin-sdk): validate checks apiVersion and the manifest rules exactly as an install does
The SDK accepted any number as apiVersion while the host refuses anything
but a positive integer no newer than its plugin API, a false green for an
author. The manifest patterns, sets and caps now live in the host's
protocol/manifest-rules.ts, and gen-plugin-facts writes them and
PLUGIN_API_VERSION into the SDK, so check:plugin-facts covers them. One
manifest corpus is judged by both validators in a server test (the SDK may
only be stricter, case by case), and a typecheck test holds the SDK's plugin
API types to the host child's copy, which gains rawBodyBase64.
2026-10-10 20:16:08 +02:00
Maurice 15db688586 refactor(reservations): one reservation type catalog in shared feeds the server's type lists
RESERVATION_TYPES lists every type with its flags (transport, carrier,
rental, stay, legs, creatable through MCP), and the named subsets derive
from it in picker order. The MCP transport and booking enums, the share
view's travel filter, the road trip carrier query and the shared
CARRIER_TYPES now read it. The reservation response types type and status
as the known values or any stored string, so rows outside the catalog still
pass; requests stay as tolerant as before.
2026-10-10 20:16:08 +02:00
Maurice 5e797463fa refactor(permissions): the permission catalog and its decision rule live in shared
PERMISSION_ACTIONS, PermissionLevel, PermissionKey and evaluatePermission()
move to shared/src/permissions. PermissionsService decides through
evaluatePermission and re-exports the catalog for its importers, and
@RequirePermission takes a PermissionKey, so a misspelt action fails to
compile. Levels, defaults and every verdict stay as they were.
2026-10-10 20:16:07 +02:00
Maurice 8d716aec2f refactor(budget): money arithmetic lives in shared and the settlement uses it
toMinor, sumMinor, splitEqualShares and currencyDecimals move to
shared/src/money. The budget service and its MCP tools call them instead of
private copies, and the share table both sides were pinned to becomes the
spec of the shared function. Stored values and every computed figure stay
the same.
2026-10-10 20:16:07 +02:00
Maurice e5a2af5999 chore(server): size, strict and warning baselines follow the error-envelope work 2026-10-10 20:16:07 +02:00
Maurice e7c1acfb47 chore(server): lint:mcp-zod and lint:service-http hold inline tool zod and error envelopes to a shrinking baseline 2026-10-10 20:16:07 +02:00
Maurice c353302e3f refactor(server): packing templates, road trip, day and booking-import services refuse with DomainError 2026-10-10 20:16:07 +02:00
Maurice 6cff4802bf refactor(mcp): tool ids derive from the shared idSchema instead of an inline zod chain per field 2026-10-10 20:16:06 +02:00
Maurice f9e756391c refactor(server): accommodation and packing item writes run as one use case per write, so REST and MCP send the same events 2026-10-10 20:16:06 +02:00
Maurice aba2ed1ee3 refactor(server): places, unsplash, vacay, collections and file downloads raise DomainError, so REST and MCP stop translating results 2026-10-10 20:16:06 +02:00
Maurice e81efcbc57 refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes 2026-10-10 20:16:05 +02:00
Maurice 66042ae1c7 feat(server): services refuse with a DomainError that REST and MCP answer in their existing shapes 2026-10-10 20:16:05 +02:00
Maurice 064fba5c0c test(server): the provider-orchestrator boundary case gets an id of its own
Both boundary rules arrived as BOUND-012; the provider rule's case is now
BOUND-014.
2026-10-10 20:16:04 +02:00
Maurice 2c3fb15263 style(server): format the files the health lanes added or moved
Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
2026-10-10 20:16:04 +02:00
Maurice a28044dbd4 chore(server): lint:boundaries holds the user-connection repositories and the journey read at their new count
The module comment no longer spells an @InjectRepository call the scanner took
for a foreign Users injection in nest/database, and journey's Users entry is
gone now that it reads the Immich flag through UserImmichRepository.
2026-10-10 20:16:04 +02:00
Maurice 912b8ff96b refactor(place-import): the Naver pager declares its page result, so the moved reader passes the strict ratchet
The reader left places.service with four strictNullChecks errors that a new
file may not carry. Declaring fetchPage's result as a page or a list import
error lets the 'error' check narrow it; the places and maps entries drop off
the strict baseline.
2026-10-10 20:16:04 +02:00
Maurice c859cf3624 test(server): the new demo-gate, timestamp, JSON-column and maps tests add no ESLint warnings
Non-null assertions become explicit guards, the two demo-gate loops collect
their answers with Promise.all, and the MapsService imports the maps tests no
longer use are gone. The src counts the refactors lowered are taken off the
warnings baseline.
2026-10-10 20:16:03 +02:00
Maurice 1a1257ccac docs(maps): the places-provider seam names both keyed providers it now carries 2026-10-10 20:16:03 +02:00
Maurice 04f38f9876 chore(server): eslint keeps fast-xml-parser inside place-import and the WebDAV client 2026-10-10 20:16:03 +02:00
Maurice 1389fe6984 refactor(place-import): file and shared-list readers move into their own domain over one gpx and one kml codec 2026-10-10 20:16:03 +02:00
Maurice dc42fe9a5a chore(server): lint:boundaries refuses a provider that imports the orchestrator above it 2026-10-10 20:16:02 +02:00
Maurice 80e64b4f4d refactor(maps): key selection, place details, marker photos and link resolving leave the maps orchestrator 2026-10-10 20:16:02 +02:00
Maurice 0cedff94a0 refactor(maps): wikimedia lookups and brand logos move into their own client 2026-10-10 20:16:02 +02:00
Maurice 79ba003bfe refactor(maps): nominatim and overpass calls move into an OpenStreetMap client 2026-10-10 20:16:02 +02:00
Maurice 863b02a549 refactor(maps): google place lookups go through the places provider 2026-10-10 20:16:01 +02:00
Maurice 3618a4ab9f refactor(maps): the searchText field mask lives beside the Google places provider 2026-10-10 20:16:01 +02:00
Maurice 526b2c105a fix(mcp): McpSharedModule imports the realtime module its guards inject
McpToolGuardsService injects RealtimeService, and McpSharedModule counted on
some other module of the app having imported the global RealtimeModule.
AuthModule used to do that for every domain that imported it for the demo
check; with those imports gone, a test app assembling ShareModule alone no
longer resolved the guards. The module now names the dependency itself.
2026-10-10 20:16:01 +02:00
Maurice cd0c27921c build(server): lint:boundaries holds foreign repository injections to a baseline
A domain injecting the repository of a table another domain owns couples
the two below the service layer, and nothing counted it. The new
foreignRepositories rule reads every @InjectRepository under src/nest,
looks the entity up in scripts/repository-owners.json (an injected entity
without an owner stops the run) and holds each domain/entity pair to the
baseline, which only shrinks: 209 pairs today.
2026-10-10 20:16:00 +02:00
Maurice fe35e26313 refactor(trip-membership): domains ask TripAccessService who may see a trip
Thirty services, controllers, MCP tools and RPC handlers outside the trips
domain injected TripsRepository for one call, findAccessible. They now
inject TripAccessService from the trip-membership leaf, in the same
constructor slot under the same field name; it answers with the same
access row (or undefined) and binds the id exactly as given, so each
caller keeps its own coercion and its own 404, 403 or tool error.
2026-10-10 20:16:00 +02:00
Maurice 734cbc2a94 refactor(vacay): the invite and share tools read the caller's name from UsersRepository
VacayMcp injected AuthService only to call getCurrentUser for the caller's
username and email before sending an invite or sharing the calendar, which
kept VacayModule importing AuthModule. UsersRepository.findUsernameEmail
answers the same two fields (and undefined for an unknown id, which the
tools already refuse as 'User not found.').
2026-10-10 20:16:00 +02:00
Maurice 806f3c8e62 refactor(addons): the transit-provider setting and photo-provider routes move into the shared kernel
AddonsService read the transit backend through transit/transit-provider.ts
and built photo-provider endpoint paths with memories.helpers, so the addons
domain, which nearly every domain imports for its gates, depended on
transit and memories. Both are free functions over a repository or plain
strings; they move unchanged to common/, where the transit and admin sides
read them too.
2026-10-10 20:15:59 +02:00
Maurice 846d8d4952 refactor(realtime): the gateway asks a journey-access port instead of importing the journey domain
The websocket gateway injected JourneyDomainService to decide who may join
a journey's book room, so the realtime domain, which nearly every domain
imports for its broadcasts, depended on journey and closed a cycle through
all of them. It now injects a JOURNEY_ACCESS port declared in realtime;
JourneyAccessModule (global, imported by AppModule beside the gateway
module) binds it to the same JourneyDomainService method.
2026-10-10 20:15:59 +02:00
Maurice 0c2226c5f0 refactor(realtime): the websocket welcome frame reads the running version from app-config
The gateway imported hostVersion from the plugin install code to put the
version in its welcome frame, which made the realtime domain, imported by
nearly every other, depend on plugins. The running version (image VERSION
or APP_VERSION, else package.json) is app config; runningVersion() holds it
and the plugin host-compat checks call the same function.
2026-10-10 20:15:59 +02:00
Maurice 01fd4e3739 refactor(admin): the admin routes other domains serve keep their DTOs in those domains
NotificationsController, SettingsController, OidcController and the packing
template controller each imported their body DTO from admin/admin.dto.ts,
and AdminController imported the same four without using them. Those
imports tied the notifications, settings, packing and oidc domains to the
admin domain and through it to everything admin depends on. The classes
move unchanged (same names, same @trek/shared schemas) into each domain's
own dto file.
2026-10-10 20:15:59 +02:00
Maurice e40bc71e9f refactor(plugins): the plugin RPC kit moves out of the plugins domain into src/nest-rpc
Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors
and PluginGuards, and every domain module imported PluginGuardsModule, all
of it inside src/nest/plugins, while PluginsRuntimeModule imports those same
domains so their handlers exist at boot. That made the plugins domain part
of every cycle. The kit imports no domain but the protocol tables and
permissions/addons for the guards, so it moves to src/nest-rpc beside
src/nest-mcp, with its tests, its README and its extraction rule; the
runtime module keeps its domain imports, which the boot-time coverage check
of the wire surface needs in any subset app.
2026-10-10 20:15:58 +02:00
Maurice cc79f561e5 refactor(auth): guards, auth decorators and ephemeral tokens move into an auth-core kernel
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global
and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral
token service lived in the auth domain, so every controller that checked a
session depended on the whole account module and its imports. They import
no domain themselves; under src/nest/auth-core they are shared kernel like
common/, every importer points there directly, and AtlasModule and
NotificationsModule stop importing AuthModule, which they only did for the
demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic
domain edges, the largest cycle shrinks from 46 to 40 domains.
2026-10-10 20:15:58 +02:00
Maurice 1a9855cf70 refactor(mcp): the demo-mode write block runs once, in the registry's tool gate
Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
2026-10-10 20:15:57 +02:00
Maurice 8350d04a63 refactor(db): the Immich, Synology and AirTrail columns of users get repositories of their own
UserImmichRepository, UserSynologyRepository and UserAirtrailRepository hold the eighteen connection statements UsersRepository carried, on the same users table and with their statements unchanged, provided through a small module as factories over the EntityManager. ImmichService, SynologyService, AirtrailService and JourneyService inject them instead of the account repository. UserRow drops the dead immich_access_token column (the table keeps it), and a shape test pins the account repository's size.
2026-10-10 20:15:57 +02:00
Maurice 3b58694eb9 refactor(settings): instance settings are named through one typed register
app-settings.registry.ts lists every app_settings key the server reads by name with its shape, default, environment override and flags. The admin form's keys, its masked and encrypted handling and the public config's reads derive from it, isOidcConfigured() replaces the three issuer/client-id checks in AuthService, and OidcService resolves its values through the same rule while still requiring the client secret. Every literal getValue() read goes through readAppSetting()/resolveAppSetting(), and ESLint refuses a new one.
2026-10-10 20:15:56 +02:00
Maurice 42d75a2b7e test(auth): pin how the four OIDC checks read the environment and the settings
The login toggles' legacy fallback, the public config, the lockout guard of the settings save and the provider config each decide on their own whether OIDC is configured. These cases hold what each answers, including that only the provider config also wants the client secret, before the checks are shared.
2026-10-10 20:15:56 +02:00
Maurice e316ccf3bb perf(db): the calendar export, the journey reads and the stop reorders read and write in batches
The ICS export reads the stops and notes of all dated days in two statements instead of two per day, the journey's full read and entry list take the source trip titles in one join instead of one read per entry, and reordering a day's stops or sorting it by time writes every position in one CASE update. Results are unchanged: each day keeps its order (the id settles a tie), a missing trip still names null, and an id listed twice keeps its last position.
2026-10-10 20:15:56 +02:00
Maurice 4a3c97e500 docs(server): name the query API per statement shape and hold QueryBuilder calls to a baseline
server/CLAUDE.md now says which API new repository code uses: Kysely for join, aggregate and set-based statements, the entity methods for single-table CRUD, no new QueryBuilder calls. The TrekRepository docstring no longer calls Kysely an escape hatch. lint:query-api counts this.qb()/createQueryBuilder() per repository file against a baseline that may only shrink and runs in the server CI job.

The same file documents the JSON column and timestamp rules from the two changes before.
2026-10-10 20:15:56 +02:00
Maurice 91deedae8b refactor(db): JSON columns are decoded through one declared schema per column
decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
2026-10-10 20:15:55 +02:00
Maurice a926990e4a fix(db): timestamp columns are written and stored in one canonical spelling
A DbTimestamp brand, produced only by dbNow(), types the expiry writes of OAuth tokens and password reset links, and collection edits stamp updated_at the same way. parseDbTimestamp() reads the stored text as UTC and replaces the oauth-local parser; utcSuffix lives once beside it. A data migration rewrites the ISO values those writers left behind, and the OAuth session lists keep answering the expiries in ISO.

Two outputs change on purpose. collections.updated_at used to come back in two spellings: a collection never edited answered the column default (2026-01-02 03:04:05), an edited one the ISO text its writer stored (2026-01-02T03:04:05.678Z). It now answers the canonical text in every case, in the PUT and cover responses and, once the migration has run, in every read; the shared schema types it as a plain string and the client does not read it. The ISO expiries of the OAuth session lists are rebuilt from the stored second-precision text, so they end in .000Z instead of carrying the milliseconds of the write.
2026-10-10 20:15:55 +02:00
Maurice b1da50466d ci(server): strict type errors per file may only shrink
tsconfig.strict.json is the build config with strictNullChecks and
noImplicitAny on. lint:strict runs tsc over it and holds the errors per file
to scripts/strict-baseline.json (204 in 109 files today): a file may only
lose errors, a new file starts at none, and an entry above its count fails
until --update lowers it. An error outside any file or an unreadable tsc run
fails closed. It runs in the server job; the build itself stays on
strict: false.
2026-10-10 20:15:55 +02:00
Maurice 1ea435d730 ci: server and shared lint stop rewriting files, and Prettier is checked per file
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
2026-10-10 20:15:54 +02:00
Maurice c9f66156d3 ci(server): lint:warnings holds the ESLint warnings per rule to a baseline
Server lint ran without --max-warnings or a baseline, so the rules left on
warn could grow without limit. lint:warnings runs ESLint with the server's
own config through the shared tally, fails on any error or inline rule
config, and holds the warnings per rule for src, tests and eslint-disable'd
messages to scripts/eslint-baseline.json, which only falls. It replaces
lint:check in CI. no-await-in-loop, no-non-null-assertion and no-console come
in as warnings held at today's counts. npm run lint no longer rewrites files;
lint:fix does.
2026-10-10 12:08:37 +02:00
Maurice cfef8742f4 docs(server): the Nest README is a blueprint again, and guides may only name paths that exist
server/src/nest/README.md drops from 988 lines to under 200: the module
shape with weather as the reference, the cross-cutting pieces, the guards,
parity and the test layout, without the bridge playbook and the services
recipe that contradicted the code. The migration log moves verbatim to
server/docs/nest-migration-history.md, marked historical.

scripts/ci/doc-paths.mjs resolves every backtick path in the CLAUDE.md files,
the root README, the blueprint and the wiki against the tracked files. The
paths already missing are listed in a baseline that only shrinks, and the
changes job runs it so docs-only changes are checked too. The wiki no longer
says the Playwright flows stay out of CI.
2026-10-10 12:01:07 +02:00
Maurice a4d0fc65f0 chore(i18n): drop the photos, register and stats locale files
None of their keys is read anywhere in client, server, shared or plugin-sdk,
neither literally nor through a key built from a prefix: the photo, sign-up
and stats screens they were written for are gone. All 27 locales lose the
same three files, so parity holds.
2026-10-10 11:54:10 +02:00
Maurice 7ee8333aeb refactor(server): types.ts keeps only imported types and derives Trip from the schema
Thirteen interfaces nobody imported are gone. Trip is now the selectable row
of the generated TripsTable, so currency, is_archived and reminder_days read
as the nullable columns they are and feed_token and reminder_sent_for exist.
2026-10-10 11:52:07 +02:00
Maurice 9a8191d367 chore: remove root scripts nothing runs any more
The two React profiler readers were one-off analysis helpers, and the i18n
migration read client/src/i18n/translations, which has been gone since the
locales moved to shared/.
2026-10-10 11:49:00 +02:00
Maurice db8babffc7 chore(server): remove the MCP config re-export and the dormant row-shape helper
Only their own tests imported them. The MCP knobs are parsed in
app-config/parsers.ts, which tests them directly, and toCamelCase was never
wired into a plugin RPC handler.
2026-10-10 11:48:59 +02:00
Maurice 2050e796eb chore(server): drop nodemon, @vitest/coverage-v8 and @types/uuid
Nothing runs nodemon (dev goes through scripts/dev.mjs), the coverage provider
is istanbul, and uuid 14 ships its own types. uuid itself stays: the OIDC and
Unsplash services import it.
2026-10-10 11:47:45 +02:00
Maurice 889afeae92 build(scripts): server ratchets share one baseline library at the repository root
scripts/lib/ratchet.mjs carries the tree walk, the CRLF-safe reader and the
baseline handling the client ratchets already use, next to a warning tally and
a format comparison that take ESLint and Prettier from the calling workspace.
server lint:size now runs on it with the same messages and exit codes, and the
libraries have node:test suites that CI runs in the server job.
2026-10-10 11:45:27 +02:00
Maurice 5644e3d77e fix(vacay): the desktop calendar no longer requests the trips again on every vacay refresh
Every vacay refresh on desktop (opening the page, a vacay:settings or
vacay:invite update from another member) sent a second GET /trips just to
redraw the same trip dots, even when the leave-year window had not changed.

Cause: useVacayCalendarLogic keyed the trip dot reload on the year settings
object when tripsFollowWindowShape was false (the desktop grid). loadAll()
replaces that object with a deep-equal copy on every refresh, so the effect
ran again. The phone screen already keyed it on the window shape.

Fix: the trip dots always reload on the window shape (year type, start month,
start day, hire date), which covers every field the dot filter reads. The
option only existed to keep the desktop behaviour, so it is removed from the
hook and from both callers.

Red test: FE-COMP-VACAYCAL-010 (useVacayCalendarLogic.test.ts) failed on the
unfixed code with "AssertionError: expected "list" to be called 1 times, but
got 2 times".

Changed assertions:
- useVacayCalendarLogic.test.ts FE-COMP-VACAYCAL-010: was "the desktop mode
  reloads trips on every new settings object" expecting tripsApi.list to be
  called 2 times after a deep-equal settings replacement; now expects 1.
- FE-COMP-VACAYCAL-011 no longer passes tripsFollowWindowShape and no longer
  names the phone mode; its assertions are unchanged.
2026-10-09 19:00:47 +02:00
Maurice 694e11c117 fix(vacay): a failed user load in the desktop invite and share dialogs no longer shows a stale list
When loading the users that can be invited to a fusion or given a calendar
share failed on desktop, the dialog kept showing the list from the last load,
so a user could pick someone who is no longer available and only learn it
from the failed send. The phone sheets already emptied the list and showed
their "no users available" state.

Cause: useVacayUserPicker only emptied the list on a failed load when its
clearOnLoadError option was true; the desktop VacayPersons and
VacaySharedCalendars dialogs passed false, a leftover of the old silent
catch in the desktop dialogs.

Fix: the hook always empties the list when the load fails. The option only
existed to keep the desktop behaviour, so it is removed from the hook and
from all four callers.

Red test: FE-COMP-VACAYPICK-002 (useVacayUserPicker.test.ts) failed on the
unfixed code with "AssertionError: expected [ { id: 2, username: 'bob' },
...(1) ] to deeply equal []".

Changed assertions:
- useVacayUserPicker.test.ts FE-COMP-VACAYPICK-002: was "a failed load keeps
  the last list on desktop" expecting available to equal USERS; now expects
  an empty list.
- FE-COMP-VACAYPICK-003: was "a failed load empties the list on the phone"
  through load() with clearOnLoadError true; now drives the same expectation
  (empty list) through a phone sheet reopening, since the option is gone.
2026-10-09 19:00:47 +02:00
Maurice 33db4ea7cb fix(dashboard): the desktop currency widget always shows the rate for the currency picked last
Switching the from currency of the desktop dashboard currency widget quickly
could show the rate of the previous currency: when the older rate request
answered after the newer one, its answer overwrote the newer rates.

Cause: useCurrencyConverter only aborted a request still in flight in its
phone mode (abortStale true); the desktop dashboard passed false and fetched
without any stale guard.

Fix: the hook now always aborts the previous request when a new one starts
and when the widget goes away, and ignores answers of an aborted request. The
abortStale option only existed to keep the desktop without the guard, so it
is removed and both dashboards call useCurrencyConverter().

Red test: FE-COMP-FXHOOK-008 (useCurrencyConverter.test.ts) failed on the
unfixed code with "AssertionError: expected 1.1 to be 1.3".

Changed assertions:
- useCurrencyConverter.test.ts FE-COMP-FXHOOK-002: was "the desktop mode
  fetches without a signal, the phone mode with one" expecting the desktop
  call to be [url] only; now expects [url, { signal: AbortSignal }].
- FE-COMP-FXHOOK-003 no longer names the phone mode; its assertions are
  unchanged. All tests call the hook without the removed option.
2026-10-09 19:00:47 +02:00
Maurice 1ad0ef6d78 refactor(settings): photo provider cards read their state from the shared hook
With the badge and toast options gone from usePhotoProviderConnections,
the desktop and phone cards called the hook identically and then derived
the same seven per card values (fields, config, values, badge, testing,
whether save and test have a route) line for line, which lint:dup counts
as a copied block of 25 lines in MPhotoProvidersSection.tsx.

The hook now hands each card those values through providerCard, and both
views keep only their markup. Nothing the user sees changes.

New test FE-COMP-PHOTOCONN-011 covers providerCard. No existing assertion
changed.
2026-10-09 19:00:47 +02:00
Maurice 24461ecf76 fix(admin): the default map template offers the Amap presets users can pick
In the admin's default user settings, the map template dropdown listed
seven tile presets, while every user's own map settings listed nine: the
same seven plus Amap and Amap Satellite. An admin running an instance for
people in mainland China could not pick the one basemap that works well
there as the default, short of pasting its URL by hand.

The Amap presets (57eac32f9) were added to the two user preset copies and
not to the admin's separate copy; nothing in that change or later gives a
reason, and the server stores any template as a default. The later
refactor kept the gap as DEFAULT_MAP_PRESETS beside MAP_PRESETS. There is
now one MAP_PRESETS list, read by the user settings and the admin defaults
on both desktop and phone.

Regression test FE-ADMIN-DUS-029 (DefaultUserSettingsTab.test.tsx) was red
on the old code: "Unable to find role="button" and name "高德地图 (Amap)"".

Changed assertions:
- useMapSettingsForm.test.ts FE-COMP-MAPFORM-001: it expected the admin list
  DEFAULT_MAP_PRESETS to hold 7 presets and the user list to add Amap on
  top; it now expects the one list to hold 9 presets including both Amap
  templates.
2026-10-09 19:00:47 +02:00
Maurice d6850756c6 fix(settings): managed installs no longer offer photo providers or AirTrail on the phone
On a managed install the phone's Integrations section still showed the
Immich and Synology Photos cards and, with the addon on, the AirTrail
connection form. Each asks for the address of a server the reader runs at
home, which a managed install cannot reach, so saving or testing could only
time out. The desktop has hidden both since the managed mode landed
(#1946, 3a0604b3f), whose message says these "connect to a server the
reader runs at home, and there is no route from here to one".

That change gated the phone's AI section but missed these two, and the
user facing addon list still carries a photo provider that was enabled
before the switch, so the cards did render. The phone now applies the same
!managed gate as the desktop IntegrationsTab.

Regression test FE-MOB-SETINT-008 (MSettingsIntegrations.test.tsx) was red
on the old code: "expected document not to contain element, found <div
data-testid="photo-providers"> instead".

No existing assertion changed.
2026-10-09 19:00:47 +02:00
Maurice 76b1e67be8 fix(settings): the desktop photo provider badge follows the live status check
On the desktop, the Immich (or Synology) card could show "Connected" for a
server that was down. The settings page loads two answers at once: the
saved settings, whose connected flag for Immich only says that a URL and an
API key are stored, and the status route, which actually reaches the
server. Whichever landed last set the badge.

usePhotoProviderConnections had a statusRouteOwnsBadge option that only the
phone turned on. The option is gone: whenever a provider declares a status
route, only that route sets the badge, on both views. A provider without a
status route still takes the badge from its settings read.

Regression test FE-COMP-PHOTOPROVIDERS-028 (PhotoProvidersSection.test.tsx)
was red on the old code: "Unable to find an element with the text: Not
connected" (the late settings read had turned the badge to Connected).

Changed assertions:
- usePhotoProviderConnections.test.ts FE-COMP-PHOTOCONN-005: it expected the
  later settings read (connected: false) to override the status route's
  connected: true; it now checks that a provider without a status route
  takes the badge from the settings read.
- usePhotoProviderConnections.test.ts: render() no longer takes the option,
  so every call drops its true or false argument.
2026-10-09 19:00:46 +02:00
Maurice 0b90fc8cf5 fix(settings): a failed photo provider test reads "Immich: reason" on the desktop
On the desktop, a failed photo provider connection test toasted "Could not
connect to Immich : Auth failed" with a stray space before the colon, and
"Could not connect to Immich " with a trailing space when the server gave
no reason. The phone already read "Could not connect to Immich: Auth
failed".

usePhotoProviderConnections took a testErrorGap option that put a space
between the sentence and ": reason", and the desktop passed ' '. The option
is gone, so both views join the reason directly.

Regression test FE-COMP-PHOTOPROVIDERS-027 (PhotoProvidersSection.test.tsx)
was red on the old code: "Unable to find an element with the text: Could
not connect to Immich: Auth failed" (the toast read "Could not connect to
Immich : Auth failed").

Changed assertions:
- usePhotoProviderConnections.test.ts FE-COMP-PHOTOCONN-010: the desktop
  toast was 'memories.connectionError(Immich) : bad key', now
  'memories.connectionError(Immich): bad key'; it also checks that a
  failure without a reason toasts the bare sentence.
2026-10-09 19:00:46 +02:00
Maurice 12962480d5 fix(settings): notification settings show an error when they cannot load
When the notification preferences failed to load, the matrix on both the
desktop tab and the phone screen stayed on its loading line for good and
nothing told the user anything went wrong. A failed load of the webhook and
ntfy settings was just as silent and left empty fields that looked like
nothing was saved.

Both loads in useNotificationSettings ended in .catch(() => {}) and had no
cancelled flag. Each failure now shows the common error toast, a failed
preferences load sets loadFailed so both views replace the loading line
with the error text, and both loads ignore a response that arrives after
the screen is gone.

Regression test FE-COMP-NOTIFSETTINGS-010 (useNotificationSettings.test.ts)
was red on the old code: "expected "vi.fn()" to be called 2 times, but got
0 times". FE-COMP-NOTIFSETTINGS-011 covers the cancelled flag and
FE-COMP-NOTIFICATIONS-032 the desktop view.

Changed assertions:
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-002: a failed load used
  to expect the "Loading..." line to stay; it now expects "Error" and no
  loading line.
2026-10-09 19:00:46 +02:00
Maurice aa759ba54a fix(settings): phone notification toggles save only the switch that was tapped
On the phone, every notification toggle sent the whole preference matrix as
it looked when the switch was tapped. With two toggles in flight the later
request could overwrite the earlier one on the server, and when the first
request failed the screen rolled that switch back while the second request
had already saved its value anyway.

The cause was the sendWholeMatrix option of useNotificationSettings, which
only the phone turned on. The server merges whatever cells it receives, so
the desktop already sent just the toggled cell. The option is gone and both
views send only the cell that changed.

Regression test FE-MOB-SETNOTIF-035 (MSettingsNotifications.test.tsx) was
red on the old code: "expected [ { trip_invite: { inapp: false,
webhook: false } }, { trip_invite: { inapp: false, webhook: true } } ] to
deeply equal [ { trip_invite: { inapp: false } }, { trip_invite:
{ webhook: true } } ]".

Changed assertions:
- useNotificationSettings.test.ts FE-COMP-NOTIFSETTINGS-004: the phone
  toggle body was { trip_invite: { inapp: false, email: false } }, now
  { trip_invite: { inapp: false } } (the hook is built with the phone's
  remaining option, skipMaskedToken).
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-026: the PUT body was
  { trip_invite: { inapp: false, webhook: false } }, now
  { trip_invite: { inapp: false } }.
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-032: the mock told the
  in-app write apart by body.trip_invite.webhook === false, which only the
  whole matrix carried; it now checks for the inapp key.
2026-10-09 19:00:46 +02:00
Maurice df7bb9a5e1 fix(storage): Test on a mirrored phone storage row probes the backends on the form
On the phone storage panel, Test on a mirrored backend sent the mirror
itself to POST /api/admin/storage/test. The server expands a mirror by
looking its primary and replicas up by name in the SAVED storage
config, so the phone tested the saved settings instead of what the
operator had just edited, and a replica added but not saved yet made
the whole test fail with "references unknown backend". The desktop
probes the primary and each replica with their draft options and
merges the results.

Cause: the phone called admin.test with the mirror candidate, while the
desktop called admin.testMirror with mirrorProbeTargets. The choice
lived in each shell's markup.

Fix: useStoragePanel now offers loaded.testRow(row), which tests a
plain row as it is and a mirrored row through admin.testMirror with
mirrorProbeTargets from the draft. Both shells call it.

Red test: FE-MOB-MSTOR-018 (new, MAdminStoragePanel.test.tsx) failed
with "AssertionError: expected [ 'mirror' ] to deeply equal
[ 'backups-local', 'off-box' ]". FE-ADMIN-STOR-PANEL-017 (new,
useStoragePanel.test.ts) covers testRow itself.

No existing assertion changed.
2026-10-09 19:00:46 +02:00
Maurice c8e21b9128 fix(admin): the phone addon list wordmark follows an OS theme switch under auto
With the appearance set to auto, switching the OS between light and
dark left the TREK wordmark in the phone admin addon header in the old
colour (dark text on a dark page or the reverse) until something else
made the screen render again. The desktop header switches right away.

Cause: the phone worked the flag out from the dark_mode setting and
window.matchMedia during render, and nothing re-renders the screen
when the media query flips. The desktop uses useIsDark, which watches
the .dark class that applyAppearance() writes.

Fix: MAdminAddonManager uses useIsDark like AddonManager.

Red test: FE-MOB-AADD-037 (new, MAdminAddonManager.test.tsx) failed
with "Expected the element to have attribute: src="/text-light.svg""
after the .dark class was added under auto.

Changed assertions in tests/unit/mobile/admin/MAdminAddonManager.test.tsx:
- FE-MOB-AADD-003 (renamed from "dark mode and auto+prefers-dark swap
  the wordmark" to "the dark palette swaps the wordmark"): it expected
  window.matchMedia to be called with "(prefers-color-scheme: dark)";
  that expectation is gone, since the screen now reads the .dark class.
  The test now sets that class instead of the dark_mode setting and a
  matchMedia stub, and still expects src "/text-light.svg". afterEach
  removes the class again.
2026-10-09 19:00:46 +02:00
Maurice 55ccb903d1 fix(admin): the phone addon list shows the Collab and AI Parsing icons
On the phone admin addon list, the Collab and AI Parsing tiles showed
the generic puzzle piece, while the desktop shows the people and
sparkles icons for them.

Cause: the addons table stores 'Users' for collab and 'Sparkles' for
llm_parsing, and the phone's ICON_MAP had no entry for either, so both
fell back to Puzzle. The desktop map gained them earlier; the phone map
was never updated.

Fix: the phone ICON_MAP now carries Users and Sparkles, like the
desktop one.

Red test: FE-MOB-AADD-036 (new, MAdminAddonManager.test.tsx) failed
with "expect(received).toBeInTheDocument(): received value must be an
HTMLElement or an SVGElement" for svg.lucide-users.

No existing assertion changed.
2026-10-09 19:00:46 +02:00
Maurice 8dd8635b87 fix(plugins): "Update all" on the phone updates the plugins one after another
On the phone plugins panel, "Update all" fired every pending update at
the same time. Only one row can show as busy, so the other rows stayed
clickable while their own install was running, and the list reloaded
once per plugin as each update finished. The desktop runs the updates
one after another, and the hook's own comment calls the parallel way
wrong.

Cause: usePluginsAdmin kept the phone's parallel loop behind the
updateAllInParallel option, which only the phone set.

Fix: the option is gone; updateAll always waits for each update before
starting the next, on both shells.

Red test: FE-ADMIN-PLUGINS-HOOK-009 (usePluginsAdmin.test.ts) failed
with "AssertionError: expected [ [ 'a', undefined ], …(1) ] to deeply
equal [ [ 'a', undefined ] ]".

Changed assertions in src/components/Admin/plugins/usePluginsAdmin.test.ts:
- FE-ADMIN-PLUGINS-HOOK-009 (renamed from "with updateAllInParallel
  every update starts at once, without a version" to "Update all starts
  only the first update, without a version"): pluginUpdate calls were
  [['a', undefined], ['b', undefined]], now [['a', undefined]]. It and
  the mounted() helper no longer pass the removed option.
- FE-ADMIN-PLUGINS-HOOK-008: only its name lost "by default".
2026-10-09 19:00:46 +02:00
Maurice a09d71ed12 fix(storage): a phone migration that fails to start drops the rest of the queue and says so
On the phone storage panel, when the operator accepted several category
moves and the first migration failed to start (for example the server
was busy), only that error was toasted. The other accepted moves stayed
in a hidden queue with no hint, and started on their own later whenever
the panel state happened to refresh. The desktop drops the rest of the
queue, names it in a storage.migrate.queueDropped toast and refreshes
the state, so the operator sees what did not move and saves again.

Cause: useStoragePanel only dropped the queue when the
dropQueueOnFailure option was set, and only the desktop set it.

Fix: the option is gone and useStoragePanel always drops the rest of
the queue on a failed start, toasts it and refreshes, for both shells.

Red test: FE-ADMIN-STOR-PANEL-011 (useStoragePanel.test.ts) failed with
"AssertionError: expected "vi.fn()" to be called 2 times, but got 1
times".

Changed assertions in src/components/Admin/storage/useStoragePanel.test.ts:
- FE-ADMIN-STOR-PANEL-011 (renamed from "by default a failed migration
  start only toasts and keeps the rest queued"): toast.error was called
  once, now twice; migrationQueue had length 1, now 0; refreshState was
  not called, now it is.
- FE-ADMIN-STOR-PANEL-012: no assertion changed; it no longer passes the
  removed dropQueueOnFailure option.
2026-10-09 19:00:46 +02:00
Maurice abbd2a94ce fix(admin): a failed "Load more" in the desktop release history keeps the list and can be retried
On the desktop admin GitHub panel, when "Load more" failed (for example
GitHub rate limiting), the whole release history was replaced by the
error card for the rest of the session: no list, no Load more button,
no way to retry. Had the user found a way to load again, the next load
would also have skipped the page that failed.

Cause: in its one page per load mode useGithubReleases advanced the
page counter whether the request worked or not and never cleared the
error, and GitHubPanel showed the error card whenever an error was set.
The phone (fillPages mode) already held the page, cleared the error on
the next good load and drew the error inline under its timeline.

Fix: the one page mode now moves the page on only after a good load and
clears the error then, like fillPages. GitHubPanel shows the error card
only when nothing has loaded yet; after that it keeps the timeline,
shows the error as a line above Load more and leaves the button there.
The page walk difference behind fillPages stays as it was.

Red tests:
- FE-COMP-GHREL-HOOK-012 (new, useGithubReleases.test.ts) failed with
  "AssertionError: expected [ 1, 2, 3 ] to deeply equal [ 1, 2, 2 ]".
- FE-ADMIN-GH-017 (new, GitHubPanel.test.tsx) failed with "Unable to
  find an element with the text: /v1\.\d\.0/" after the failed load.

Changed assertions in src/components/Admin/useGithubReleases.test.ts:
- FE-COMP-GHREL-HOOK-004 (renamed from "sets the error and still moves
  the page on" to "sets the error and holds the page"): requested pages
  were [1, 2, 3], now [1, 2, 2].
2026-10-09 19:00:46 +02:00
Maurice e4e49381d8 fix(admin): phone GitHub and About links point at the liketrek/TREK repository
On the phone, the admin GitHub panel showed "Latest updates from
mauriceboe/TREK" and its releases, bug report, feature request and wiki
links, as well as the same three links in the settings About section,
all went to the old mauriceboe/TREK repository. The desktop panels
already use liketrek/TREK.

Cause: the phone screens kept the repository name from before the move
to the liketrek organisation.

Fix: MAdminGitHubPanel and MSettingsAbout now use liketrek/TREK, like
GitHubPanel and AboutTab. No other user facing mauriceboe/TREK link
is left in the client (the Ko-fi, Buy Me a Coffee and Docker image
names are unchanged and identical on both sides).

Red test: FE-MOB-SET-ABOUT-001 (new, MSettingsAbout.test.tsx) failed
with "Expected the element to have attribute: href=
https://github.com/liketrek/TREK/issues/new?template=bug_report.yml".

Changed assertions in tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx:
- FE-MOB-AGH-001: wiki href was https://github.com/mauriceboe/TREK/wiki,
  now https://github.com/liketrek/TREK/wiki
- FE-MOB-AGH-004: subtitle was "Latest updates from mauriceboe/TREK",
  now "Latest updates from liketrek/TREK"; GitHub link was
  https://github.com/mauriceboe/TREK/releases, now
  https://github.com/liketrek/TREK/releases
2026-10-09 19:00:46 +02:00
Maurice 6c916cc257 fix(collab): a note category shows the same colour on the phone as on desktop
When the notes of one category carried two colours (a category recolour
that failed halfway, or a note written over the API), the desktop panel
painted the category in the colour of the last such note in the list
while the phone notes tab used the first one, so the same category had
different colours on the two devices.

The phone's buildCategoryColorMap took the first colour on the stated
assumption that every note in a category has the same colour, so first
seen equals all seen; it was meant to match the desktop and only
differed where that assumption breaks. The desktop rule (since v2.6.0)
is the original. buildCategoryColorMap now keeps the last colour seen
and the desktop panel builds its map from the same function, merged
over its empty categories as before.

Red before the fix: tests/unit/mobile/trip/collabModel.test.ts
FE-MOB-CLBM-023 failed with "expected { Ideas: '#ef4444' } to deeply
equal { Ideas: '#10b981' }".

Changed assertion in tests/unit/mobile/trip/collabModel.test.ts
FE-MOB-CLBM-011 (renamed from "keeps the first colour seen per
category" to "keeps the last colour seen per category"): the expected
map was { Ideas: '#ef4444' } and is now { Ideas: '#10b981' }.
2026-10-09 19:00:45 +02:00
Maurice d118423570 fix(journey): the desktop gallery Upload button shows the files counting up again
Uploading photos into a journey gallery on desktop gave no sign that
anything was happening until the toast at the end; the Upload button
stayed clickable the whole time. Up to v4.0.0 the button sat inside the
gallery and showed a spinner with "Uploading done/total" while it was
disabled. When the button moved up into the page header (onRegisterUpload)
that indicator was dropped, and the count kept being tracked with no
reader.

useGalleryUpload now also returns the progress it tracks, the gallery
hands it up through onUploadProgress into useJourneyDetail, and the
page's Upload button is disabled and shows the spinner with
journey.editor.uploadingProgress while the files go, as it did before.

Red before the fix: src/pages/JourneyDetailPage.wiring.test.tsx
FE-JRN-DETWIRE-043 failed with "expected undefined to be [Function
Mock]": the gallery was never given an onUploadProgress callback and
the button never showed a count. FE-JRN-GALLERY-021 and
FE-JRN-GALLERYUPLOAD-007 cover the gallery and the hook. No existing
assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 49e618cbc7 fix(journey): hiding suggestions on desktop no longer throws when the preference save fails
On the desktop journey page, the eye toggle that hides the suggested
entries flipped the view and then saved the preference. When that save
was refused (offline, a server error) the promise rejected into a click
handler nothing awaited, leaving an unhandled rejection behind.

The phone already treats this as a view preference: it keeps the local
flip until the next load and swallows the failure on purpose. The
desktop toggle now does the same.

Red before the fix: src/pages/JourneyDetailPage.wiring.test.tsx
FE-JRN-DETWIRE-042 failed with "expected vi.fn() to not be called at
all, but actually been called 1 times" on the unhandledRejection
listener, called with [Error: offline]. No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 65448ce186 fix(journey): a failed entry save on the phone says so instead of failing silently
Saving a journey entry on the phone sheet while the server refused it
(a locked journey, a dropped connection) left the sheet sitting there
with no message, and the rejection went unhandled. The desktop dialog
toasted journey.settings.saveFailed or the server's reason.

The cause was the toastSaveError option of the shared useJourneyEntryForm
hook: only the desktop set it, and without it handleSave rethrew into a
click handler that nothing awaited. The option only kept the phone on
the broken path, so it is gone and both views toast and keep the form
open.

Red before the fix: src/components/Journey/useJourneyEntryForm.test.ts
FE-JRN-ENTRYFORM-021 failed because handleSave rejected with
"{ response: { data: { error: 'Locked' } } }" instead of toasting.

Changed assertions in FE-JRN-ENTRYFORM-007 (renamed to "a failed save
toasts for the desktop dialog and the sheet alike"): the sheet's save
was expected to reject (rejected true) with one toast in total; it now
resolves (rejected false) and the toast count is 2.
2026-10-09 19:00:45 +02:00
Maurice f19b52fb20 fix(collab): note attachments added or removed on the phone show in the Files tab at once
On the phone, attaching a file to a note, detaching one or deleting a
note with attachments left the trip's Files tab on the old list until a
reload. The desktop panel fires the local collab-files-changed event
that useTripWebSocket listens for, because the server leaves the
writer's own socket out of its broadcast; the phone notes tab never
fired it.

The event now lives in the shared useCollabNotesData hook:
uploadNoteFiles announces it once after a batch, and
announceNoteFilesChanged is what both shells call after deleting a note
or a note file. The desktop no longer fires it by hand after an upload.

Red before the fix: tests/unit/mobile/trip/MCollabNotes.test.tsx
FE-MOB-CNOTE-034 failed with "expected 0 to be 1" on the event count
after detaching a file. FE-COLLAB-NOTESDATA-009 covers the hook.
No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 0246e5c317 fix(map): a distance between near antipodal points is half the globe instead of NaN
haversineKm in src/utils/geo.ts answered NaN for some near antipodal pairs,
for example 47.3812, -10.8827 and -47.3812001, 169.1173. Rounding pushed the
haversine term a hair past 1, and asin of a value above 1 is NaN, which then
spread into route sums and range checks.

Cause: unlike the Studio legKm and the roadtrip haversineKm in
@trek/shared, the shared client helper had no clamp before Math.asin.

Fix: clamp with Math.min(1, ...) before Math.asin. With the clamp the helper
runs exactly the operations of Studio legKm in the same order (2 * 6371 is
12742), so Studio now uses haversineKm and legKm goes, with its cases carried
into the new geo tests. The roadtrip variant multiplies its terms in another
order and gives different last bits, so it stays as it is.

Red test: src/utils/geo.test.ts FE-UTIL-GEO-002 failed on the unfixed code
with "AssertionError: expected NaN to be close to 20015.086796020572,
received difference is NaN, but expected 0.0005".

Removed tests: src/components/Studio/legKm.test.ts went with legKm. Its four
cases (zero for one point, a degree of latitude about 111.19 km, the same
both ways between 8700 and 8900 km, 0,0 to 0,180 equal to 12742 * PI / 2) now
run against haversineKm as FE-UTIL-GEO-001, 003 and 004.
2026-10-09 19:00:45 +02:00
Maurice 7659f2741e refactor(places): drop the unused phone pool counter
poolCounts in the phone places browser model has had no caller since the
phone pool chips stopped showing counts in 8855b5581 (the FilterChip note in
MPlacesBrowser says counts are left out on the phone to save row space). Only
its own tests still ran it, and it had drifted from the desktop counts in
usePlacesSidebar, which count planned places against the day scoped planned
ids. Nothing on the phone should use it, so it goes, with its two tests.

No user facing change, so there is no red test: the helper is dead code.

Removed tests in src/mobile/screens/trip/places/placesBrowserModel.test.ts:
MOBILE-POOL-counts (expected all 3, planned 2, unplanned 1) and
FE-MOB-PMODEL-013 (expected all 2, tracks 1, planned 1, unplanned 1).
2026-10-09 19:00:45 +02:00
Maurice cee496c469 fix(places): coordinates pasted on the phone survive the next search pick
On the phone place form, pasting a "lat, lng" pair into the latitude field
filled both coordinates, but a later search pick whose place came without a
position emptied them again whenever an earlier pick had filled them. On the
desktop the pasted position stays.

Cause: the phone's paste handler wrote lat and lng with setForm directly, so
autoFilledRef still listed them as written by the previous pick, and
mergeResult clears such fields when the new pick says nothing about them. The
desktop goes through handleChange, which marks a field as typed by hand.

Fix: the phone's paste handler goes through handleChange for both halves, the
same as the desktop dialog.

Red test: tests/unit/mobile/trip/MPlaceEditSheet.test.tsx FE-MOB-PLEDIT-047
failed on the unfixed code with "expect(element).toHaveValue(35.7101)
Expected the element to have value: 35.7101 Received: (empty)".

No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 0bdf0fc345 fix(places): tapping a phone search suggestion keeps the last pick's address and position
On the phone place form, tapping a search suggestion emptied the address,
the coordinates, the map ids and the website that the previous pick had filled,
before the suggestion's place was even looked up. When that lookup failed, the
form was left with the new name and no position at all. The desktop form only
puts the name in and leaves the rest alone.

Cause: PlPlaceSearch handed the suggestion's name to the sheet as a search
pick (onPick({ name })), and the sheet folds every pick through mergeResult,
which clears each field the previous pick wrote when the new pick says nothing
about it.

Fix: PlPlaceSearch takes an onSuggestionName callback, the same one the
shared usePlaceSearch hook already offers, and MPlaceEditSheet only sets the
name with it, as the desktop dialog does. The resolved place still arrives
as a normal pick.

Red test: tests/unit/mobile/trip/MPlaceEditSheet.test.tsx FE-MOB-PLEDIT-046
failed on the unfixed code with "expect(element).toHaveValue(Taito)
Expected the element to have value: Taito Received: (empty)".

Changed assertions in tests/unit/mobile/trip/PlPlaceSearch.test.tsx:
FE-MOB-PLSRCH-004 expected onPick to be called first with { name: 'Louvre' }
and twice in all; it now expects onSuggestionName to be called with 'Louvre'
and onPick once. FE-MOB-PLSRCH-005, FE-MOB-PLSRCH-005b and FE-MOB-PLSRCH-024
waited for onPick to be called twice; they now wait for one call.
2026-10-09 19:00:45 +02:00
Maurice 65a657347e fix(budget): a phone ticket split keeps the share of everyone a receipt line names (#1382)
On the phone, a ticket split where a receipt line named someone who was not
ticked as a participant saved only the ticked people as members. Their share of
the line was dropped, the member amounts no longer added up to total_price, and
the settlement kept a difference nobody could clear. The desktop editor had
been fixed for this in #1382.

Cause: useExpenseForm only added the people named on ticket lines when the
caller passed ticketShareMembers, and only the desktop CostsPanel did. The
phone MCostSheet kept the old behaviour.

Fix: the hook always sends everyone a ticket line names, and the
ticketShareMembers option is gone, so both editors save the same members.

Red test: src/components/Budget/useExpenseForm.test.ts
FE-BUDGET-EXPFORM-021 failed on the unfixed code with
"AssertionError: expected { name: 'Lunch', ... } to match object { ... }":
member_ids was [1] and members held only user 1, without user 2's 6.

Changed assertions: useExpenseForm.test.ts FE-BUDGET-EXPFORM-017 used to
expect the phone run (ticketShareMembers false) to send
members [{ user_id: 1, amount: 6 }] and member_ids [1]; that phone half is
removed and the test now expects every editor to send members for users 1 and
2 with 6 each and member_ids [1, 2].
2026-10-09 19:00:45 +02:00
Maurice e870270fb9 fix(planner): the plan view panel tabs say whether their panel is open and show a focus ring
In the plan view the tabs that collapse and reopen the left and right
panels gave a screen reader no open or closed state and showed no focus
ring when reached with the keyboard. The same tabs in the tour planner
had both, because only there LeftPanelTab got the "announced" option,
and the plan view's right tab was a hand copy of the left one.

LeftPanelTab becomes PanelTab with a side prop and always sets
aria-expanded and the focus ring. The plan view's right tab renders it
mirrored, so every panel tab shares one component. The offsets are
written with literal left and right keys so lint:rtl still counts them.
The page shrinks by 11 lines.

Red test: FE-PAGE-PLANNER-016b in client/src/pages/TripPlannerPage.test.tsx
failed on the old page with
expect(element).toHaveAttribute("aria-expanded", "true").
FE-PAGE-PLANNER-LEFTTAB-005 covers the mirrored right tab.

Changed assertions (they asserted the missing state):
- TripPlannerPage.leftPanelTab.test.tsx FE-PAGE-PLANNER-LEFTTAB-003:
  not.toHaveAttribute('aria-expanded') and not.toHaveClass
  ('focus-visible:outline') are now toHaveAttribute('aria-expanded',
  'true') and toHaveClass('focus-visible:outline') (title updated).
- TripPlannerPage.leftPanelTab.test.tsx FE-PAGE-PLANNER-LEFTTAB-004: the
  "announced" prop is dropped from both renders (title updated); the
  expectations are unchanged. The file renders PanelTab instead of
  LeftPanelTab.
2026-10-09 19:00:44 +02:00
Maurice 836a845b61 fix(planner): moving a booking onto another day clears the leftover drag data
Dropping a booking on the body of another day moved it but left the
shared drag data (window.__dragData) of an earlier place or map drag in
place, while dropping it on the end zone of the day cleared both. A later
drop with nothing of its own could then read that leftover place id.

The cross day booking branch of the day body drop now clears
window.__dragData along with dragDataRef, the same as the end zone. The
two clears are one chained assignment, so DayPlanSidebar.tsx stays at
the line count lint:size holds it to.

Red test: FE-PLANNER-DAYPLAN-236 in
client/src/components/Planner/DayPlanSidebar.dragData.test.tsx failed
with "expected { placeId: '77' } to be null".

No existing assertions changed.
2026-10-09 18:58:57 +02:00
Maurice 55e6b55f4d fix(roadtrip): the stop popup shows the answers of the stop it is handed, not the previous one
The desktop road trip stop popup read its kind, length, night and
check-in answers only when it mounted. When the planner handed the open
popup another stop draft, the popup showed the new stop's name with the
previous stop's kind, length, night switch and check-in, and saving wrote
those stale answers onto the new stop.

The phone sheet already seeds the shared useStopDraftForm again for
every draft. The popup now does the same in an effect keyed on the draft,
and seeds the night switch and the check-in time from the same draft.

Red test: FE-ROADTRIP-STOPPOPUP-034 in
client/src/components/Roadtrip/RoadtripStopPopup.test.tsx failed with
expect(element).toHaveAttribute("aria-pressed", "true") on the Charging
chip after the draft was swapped.

No existing assertions changed.
2026-10-09 17:56:27 +02:00
Maurice 8c9a31e5ef fix(planner): switching an edited flight or train to car no longer brings back stops from an earlier booking
The transport form stays mounted between openings. Editing a flight or a
train after a car kept the car's stops (desktop and phone) and, on the
desktop, the from and to picks of that earlier booking as well. Switching
the type to car in the same dialog showed those stale stops and places,
and saving wrote them onto the booking. The desktop also kept stale
flight and train rows when opening a car, bus or other booking.

seedTransportRoute only reset the rows a type does not show when the
phone asked for it (resetHiddenRoutes), and never reset the car stops for
a flight or a train. It now returns every row for every type, so a seed
always replaces the whole route. The option is removed; both views get
the same clean route.

Red test: FE-PLANNER-TRANSPORTFORM-008 in
client/src/components/Planner/useTransportForm.test.ts failed with
"expected [ { location: { ... }, time: '' } ] to deeply equal []".

Changed assertions (they asserted the stale rows were kept):
- useTransportForm.test.ts FE-PLANNER-TRANSPORTFORM-005: desktop
  fromPick toBe(picked) is now toEqual({}) (title updated), and the
  resetHiddenRoutes field is dropped from seedOptions and the phone call.
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-005: desktop route
  keys toEqual(['waypoints']) is now empty fromPick, toPick, carStops and
  trainWaypoints.
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-010: desktop car
  waypoints toBeUndefined() is now two empty rows (plus empty train rows),
  and the bus route now also carries the two empty row sets.
- transportEndpoints.test.ts: resetHiddenRoutes dropped from DESKTOP and
  PHONE.
2026-10-09 17:48:16 +02:00
Maurice 1e674f040a fix(planner): a create expense click that saves nothing no longer opens the editor on the next save
In the desktop booking and transport dialogs, clicking "Create expense"
(or editing a linked expense) before the booking had a title saved
nothing, which is right, but the wish was kept. The next ordinary save
then opened the expense editor out of nowhere. The same happened after a
save that threw.

useBookingExpenseIntent stored the wish in a ref and only cleared it when
the form read it, which the desktop forms do after a successful save. The
hook now clears the wish once the save its click started has settled,
unless a newer click replaced it in the meantime. The phone sheets read
it at the start of the save and are unaffected.

Red tests:
- FE-PLANNER-RESMODAL-107 in ReservationModal.test.tsx: "expected
  "vi.fn()" to not be called at all, but actually been called 1 times".
- FE-PLANNER-BOOKINGEXPENSE-005 in useBookingExpenseIntent.test.ts:
  "expected { create: true } to be null".
- FE-PLANNER-BOOKINGEXPENSE-006: "expected { editItem: { id: 9, ... } }
  to be null".

No existing assertions changed.
2026-10-09 17:43:24 +02:00
Maurice 70f4f7cef3 fix(planner): an imported booking with source files saves and opens its expense without file support
Saving an imported booking in the desktop booking dialog when the dialog
had no upload handler but the import carried source files rejected the
save: the files were handed to the missing handler, the promise rejected
with nothing to catch it, and the expense editor the user asked for never
opened. The transport dialog already skipped the upload in that case, the
booking dialog did not.

The shared uploadBookingFiles helper now takes an optional handler and
does nothing without one, so every form gets the same guard.

Red test: FE-PLANNER-RESMODAL-106 in
client/src/components/Planner/ReservationModal.test.tsx failed with
"expected spy to be called with arguments" for onOpenExpense, plus the
unhandled rejection "TypeError: upload is not a function". The helper
case is covered by FE-PLANNER-BOOKINGFORM-008.

No existing assertions changed.
2026-10-09 17:36:59 +02:00
Maurice 07117949ef fix(mobile): editing a bus, car, taxi or ferry on the phone keeps its price (#2233)
Editing a transport without metadata of its own (bus, car, taxi, ferry
without a transit itinerary) in the phone sheet wiped the expense price
shown on the booking card. The phone built the payload with
emptyMetadataAsNull and sent metadata: null, which the reservations
service reads as "clear the column", so keepMirroredPrice had nothing to
carry price and priceCurrency into. The desktop and both booking forms
send an empty object, which keeps the price.

The option only existed to keep the phone's null, so it is gone and both
views now send the rebuilt metadata object as is.

Red test: FE-MOB-TRFRM-060 in
client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx failed with
"AssertionError: expected null to deeply equal {}".
RESV-SVC-043 in server/tests/unit/nest/reservations.service.test.ts shows
the server side: {} keeps the stored price, null drops it.

Changed assertions (they asserted the null):
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-006: payload metadata null
  is now {}.
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-011: metadata toBeNull is now
  toEqual({}).
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-027: metadata toBeNull is now
  toEqual({}).
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-014: phone metadata
  toBeNull is now toEqual({}) (title updated to match), and the
  emptyMetadataAsNull field is dropped from DESKTOP_SAVE and PHONE_SAVE.
- useTransportForm.test.ts FE-PLANNER-TRANSPORTFORM-007: metadata: null
  is now metadata: {}.
2026-10-09 17:29:22 +02:00
Maurice 62f45e7934 chore(client): lower the ratchet baselines after removing the last copied blocks
The copies inside the map renderers, the location inputs, the day plan
sidebar, the trip planner page, the time picker, studio, the store
slices and a few small components are gone, and so is the markup the
two phone booking sheets, the two phone booking tabs, the desktop
booking dialogs, the vacay sheets and the storage panel drew twice.
lint:dup now holds every file at zero, and lint:size, lint:rtl and the
eslint warning counts go down with it. Nothing goes up.
2026-10-09 16:29:40 +02:00
Maurice 69db17b5dd refactor(vacay): draw the phone user picker list with one component
The invite and the share sheet each carried the same unfolded user list. Both now render MUserPickerList with the users, the picked id and the pick handler from useVacayUserPicker. The markup is unchanged.
2026-10-09 16:29:40 +02:00
Maurice 201de0bd5b refactor(storage): draw the phone backend test result with one component
The phone storage panel rendered the same test result markup for regular and degenerate mirror backends. Both rows now render MStorageTestResult, with the same elements, classes and text as before.
2026-10-09 16:29:40 +02:00
Maurice e0fd2c296b refactor(admin): draw the photo provider marks from one icons file
The desktop and the phone addon manager each carried their own copy of the Immich and Synology SVGs. Both now import them from components/Admin/addons/PhotoProviderIcons, which has its own test. The rendered marks are unchanged.
2026-10-09 16:29:40 +02:00
Maurice c45ac648c9 refactor(bookings): draw the shared parts of the phone reservation cards once
The transports and bookings tabs drew the same title button, edit and
delete buttons, date and time row with the booking code, file chips and
delete confirmation sheet as two copies. Both cards now render them from
MReservationCardParts, and each tab still decides what a tap opens. The
rendered markup stays the same for both tabs.
2026-10-09 16:29:40 +02:00
Maurice a843b66c33 refactor(bookings): draw the notes, files and costs of the booking dialogs with one component
The desktop booking and transport dialogs ended on the same notes field,
link and files row and linked costs block. Both now render
BookingNotesFilesCosts next to them, with the same markup as before.
2026-10-09 16:29:40 +02:00
Maurice 3de3ffcd1c refactor(bookings): draw the shared phone booking fields with one component
The phone booking and transport sheets each carried their own copy of the
booking code and status row, the notes field, the traveler picker with its
avatar, and the transport sheet drew the card of a route stop twice, once
for flights and once for trains and cruises. They now render the same
components from MBookingFields next to the sheets, which take the sheet's
own translate function so every string stays what it was. The rendered
markup is unchanged.
2026-10-09 16:29:40 +02:00
Maurice e86cb6a897 refactor(bookings): let the phone plugin slots use the reservation detail plugin hook
ReservationPluginSlots in tabChrome repeated the plugin store selector
that useReservationDetailPlugins already holds for the desktop panels.
It now calls the hook, which gets its own test.
2026-10-09 16:15:49 +02:00
Maurice 60e8f1cd34 refactor(roadtrip): read the wall clock minutes and today through localDate
useMRoadtrip and useMRtCorridor each kept a nowMinutes helper and their
own check whether the selected day is today. Both now use localMinutes
and isLocalToday from utils/localDate.ts, which hold the same rules.
2026-10-09 16:15:49 +02:00
Maurice 3fcfb12749 refactor(admin): share the fold head of the Google options and the TREK API card
Both folds on the settings page opened with the same summary line and
turning chevron. FoldSummary now draws it and each fold passes its own
label; the Google fold keeps its status pill.
2026-10-09 16:15:49 +02:00
Maurice c8d8ddfd3e refactor(packing): share the template row of the two apply template menus
ApplyTemplateButton and the packing list header drew the same template
row in their menus. PackingTemplateOption now draws it; the header's
faded hover stays a parameter (fade).
2026-10-09 16:15:49 +02:00
Maurice 2a05e6d20d refactor(files): share the preview dialog frame between the PDF and Markdown previews
Both previews built the same dialog head band with the file name, the
open in tab pill and the download pill. FilePreviewDialog now draws it;
the dialog width, the body class and the icon colour stay parameters,
and each preview keeps its own body.
2026-10-09 16:15:49 +02:00
Maurice 76b4b96e16 refactor(budget): share the category reorder between the slice and the remote handler
The optimistic category reorder in budgetSlice and the budget:reordered
handler for another member's reorder grouped the items the same way in
two copies. Both now call orderBudgetByCategories from budgetOrder.ts,
so the local and the remote reorder cannot drift apart.
2026-10-09 16:15:49 +02:00
Maurice 9f1d5c4a63 refactor(atlas): draw the country and place search rows with one row component
The country rows and the geocoded place rows repeated the same button,
hover tint and trailing chevron. A ResultRow inside AtlasCountrySearch
now draws that frame and each list passes its own leading content.
2026-10-09 16:15:49 +02:00
Maurice 71b7b209dc refactor(studio): one great circle distance for the map bows and the road fetch
TravelElements ramped how far a leg bows by a local km helper and
roadRoute refused long legs by a second copy of it. Both now call legKm
from legKm.ts, the same formula with the same clamp.
2026-10-09 16:15:49 +02:00
Maurice e6930e00ac refactor(studio): share the typeface buttons and the weight line between the inspectors
The text inspector and the travel inspector each drew the same row of
typeface buttons and the same weight choice. Both now render FontButtons
and WeightLine from StudioTypeControls.tsx. The travel inspector keeps
casting its patches to Partial<BookElement>, so each side writes exactly
the fields it wrote before.
2026-10-09 16:15:49 +02:00
Maurice d08c949b14 refactor(time-picker): draw the hour, minute and AM/PM columns with one spinner column
The three columns of the time picker each repeated the same chevron up,
value box and chevron down markup. TimeSpinnerColumn draws one, and the
button style it shares with the clear button moves to timeSpinnerStyle.

The AM/PM column keeps its narrower box, smaller type and leading gap as
props, and the hour and minute boxes keep their tabular digits.
2026-10-09 16:15:48 +02:00
Maurice c1a9e98ec4 refactor(planner): share the cross-day move and end-of-day drop between the day body and the end zone
The day body and the drop zone at the end of the list both carried their
own copy of two steps: moving a stop or note dragged in from another day,
and putting a same-day row after the day's last row. Both now live in the
plan hook as moveInFromOtherDay and dropAfterLastItem.

The differences stay where they were: only the end zone passes a
reservation id, so only it moves a booking to the end, and only it clears
the drag state after the move, which it still skips on an empty day.
2026-10-09 16:15:48 +02:00
Maurice 188d867aee refactor(planner): stop echoing the day plan's props back through its hook
useDayPlanSidebar returned about 145 names, and DayPlanSidebar destructured
the same list again, the largest copied block in the client. Most of it
was the component's own props passed straight back, plus values nothing
read any more.

The optional props' fallbacks move into withDayPlanDefaults, which the
hook and the view both read the props through, so they are written once.
The hook now returns only what it computes. State that only the view
reads (the context menu, the permission flags, the hover and expansion
states, the pending note deletion) is declared in the view, and the day
note handlers come back as one notes object. The values no one read are
gone, which also drops their unused variable warnings.

No behaviour changes: every value keeps its source and its fallback.
2026-10-09 16:15:48 +02:00
Maurice da37f3618d refactor(planner): share the left panel tab and the selected place props in the trip planner page
The plan view and the tour planner view each drew the left panel's tab in
full, and the place inspector and the tour dialog each listed the same props
twice, once beside the panels and once in the phone-width sheet.

LeftPanelTab, a small presentational component at the bottom of
TripPlannerPage.tsx, now draws the tab for both views. It stays in the page
file so the physical left and right offsets keep being counted there. The
tour planner's tab passes `announced`, which keeps its aria-expanded and its
focus ring; the plan view's tab still has neither.

The page body now builds selectedPlaceProps (what the inspector and the tour
dialog both take), placeInspectorProps and tourDetailProps once and spreads
them. What differs stays at each call site: the desktop inspector's
onOpenBooking and panel widths, the sheet's edit and delete closing the
sheet, the desktop tour dialog's non-modal focus return and widths, and
canEdit and canAssign on every tour dialog.
2026-10-09 16:15:48 +02:00
Maurice eb271f0d45 refactor(planner): share the suggestion dropdown between the airport, location and address fields
AirportSelect, LocationSelect and AddressInput each carried their own copy
of the open state, the keyboard highlight, the outside click that closes the
list, the floating list with its loading line and the suggestion row.

useSuggestionDropdown now holds the open state, the highlight, the outside
click and the arrow, Enter and Escape handling. SuggestionDropdown.tsx holds
the boxed input with its icon and clear button (airport and location), the
floating list (all three), the row button and the place row content
(location and address).

What still differs between the fields stays with them or is a parameter:
the icon, the placeholder, what typing does (LocationSelect also resets the
highlight), the rows the keys walk (LocationSelect walks the trip's places
below three characters), whether the loading line may show (not while
LocationSelect offers the trip's places) and how a row lines up its content
(centred for airports). Each field keeps its own query, search and pick.
2026-10-09 16:15:48 +02:00
Maurice 03cb9fa439 refactor(map): share the marker thumbnail loading between the Leaflet and GL maps
MapView and MapViewGL each held the same photo loading effect: read the
cached thumbs, fetch the missing ones, listen for their arrival and batch
them into one animation frame. It now lives in useMarkerThumbs, which
also owns the thumb state and the place photos switch, and both maps
call it where the effect used to be.

The effect keeps its keys (the picture sources and the switch). It reads
the places through useEffectEvent, which drops the old
exhaustive-deps suppression in MapViewGL and the warning in MapView.
2026-10-09 16:15:48 +02:00
Maurice 69d2f80fe2 refactor(map): share the transport hop building between the Leaflet and GL maps
ReservationOverlay and reservationsMapbox each carried the booking type
list, the icon and arc table, the time zone aware duration and the loop
that turns a booking into waypoints, arcs and labels. All of that now
lives in transportHops.ts; each renderer keeps only its drawing.

The one difference stays a parameter: Leaflet asks for arcs that cross
the antimeridian to be copied 360 degrees over, the GL map does not
because it repeats features across world copies itself. Leaflet still
adds its transit segments on top of the shared hop.
2026-10-09 16:15:48 +02:00
Maurice 369697650f refactor(map): build OSRM leg connectors in one place
calculateSegments and the cached legs call turned each OSRM leg into the
same connector record line by line. That record now comes from
osrmLegSegment; the legs call keeps adding its durationText on top, so
both answers stay exactly as they were.
2026-10-09 15:26:27 +02:00
Maurice feef872776 ci(client): lint:pairs keeps a desktop view and its phone twin on one hook
Most features have a desktop view and a phone view, and until now each
carried its own copy of the feature's logic, so a fix on one side drifted
away from the other. With the logic moved into one shared hook per
feature, scripts/feature-pairs.json names every feature with its hooks
and the desktop and phone files that run on them.

lint:pairs fails when a listed view stops importing one of its pair's
hooks (a type only import does not count), when a listed file is gone,
when a hook sits in a view layer rather than under components, hooks,
utils or the folder of a page under pages/, and when a file under
src/mobile/screens is in no pair and not under mobileOnly with the
reason it has no twin. A new phone screen is so either wired to the
desktop feature's hook or marked as phone only on purpose. lint:dup
catches the copies that look alike; this one catches the copies written
anew.

The manifest starts with 120 features and 62 phone only screen files,
each with the reason it stays on the phone alone.

client/CLAUDE.md says how to change a feature, add one with both views
and add a phone screen.
2026-10-09 15:20:10 +02:00
Maurice 88d196bae8 ci(client): lint:dup holds copied code to a baseline that only shrinks
Most features have a desktop view and a phone view, and each carried its
own copy of the feature's logic, so SonarCloud's duplication condition
was the gate a pull request touching both failed most often, and only
after a scan of twenty minutes. lint:dup finds the same copies before
the push.

It runs jscpd's detector (@jscpd/core with @jscpd/tokenizer) over the
source files under src/ at the thresholds the SonarCloud gate applies to
TypeScript, 100 tokens over at least 10 lines, and counts per file the
lines inside a block that repeats elsewhere or in the same file. Tests
and declaration files are left out as Sonar leaves them out, and .ts and
.tsx are read as one language, so a hook that repeats a view's code is a
copy too. The files are read sorted and with readText, so every machine
counts the same. scripts/dup-baseline.json holds the files that still
hold copies with their count; a file may hold no more than its entry, a
file without an entry none, and an entry above its file or for a file
that is gone fails until --update lowers it. --list prints every block
with its other side.

The engine packages are used instead of the jscpd command, which walks
the tree in file system order, skips files past its size limits without
a word and drops every result once it meets a file it has no format for.
2026-10-09 15:20:10 +02:00
Maurice 2ccdba78d6 chore(client): lower the ratchet baselines after sharing the feature hooks
The size, layers, format and eslint baselines only go down: files that
shrank, moved or lost a copied warning leave or lower their entries.

The offline baseline drops from 212 to 170 entries. 102 desktop and phone
views no longer import src/api/ and leave the list. 60 shared hooks take
their place, because the calls those views made moved into the one hook
both of them now import. Each of the 60 is imported only by views that
were on the list before, and every api module it imports was imported by
those views before. No call is new, and none moved behind the repo or the
mutation queue, so the offline behaviour is the same as before.
2026-10-09 15:20:09 +02:00
Maurice cf5b5c1245 refactor(collections): share the save to list picker logic between desktop and phone
useSaveToCollection holds the store driven picker that
SaveToCollectionModal and MSaveToCollectionSheet each carried: loading
the lists and the membership for a new target, toggling the place in or
out of a list, the per list status, marking it visited everywhere and
opening the collections page. Both views keep their own markup.

The two copies did the same thing, so no option was needed. The load
effect now lists the membership query beside the target instead of
silencing the exhaustive deps rule; the query is derived from the
target alone, so it runs exactly as before.
2026-10-09 15:20:09 +02:00
Maurice a0a4a24395 refactor(admin): share the invite link status between desktop and phone
Whether an invite is expired, used up or active, and which status label it shows, was worked out in both user views. It now lives in inviteStatus in the admin model; each view keeps its own row markup.
2026-10-09 15:19:51 +02:00
Maurice 6d2490a3ba refactor(tours): share the planned tours filter between desktop and phone
The All, Unplanned and Planned filter of the tours list was copied into the phone selection list. It now lives in filterTours next to the other tour presentation helpers, and both lists call it.
2026-10-09 15:19:51 +02:00
Maurice f84924c56a refactor(trips): share the trip loading splash beats between desktop and phone
Both splashes carried their own copy of the four beats, the step time, the still beat and the interval that cycles them. These now live in useTripSplashBeats; each splash keeps its markup and styling. The phone follows the reduced motion setting live while the splash is up, the desktop reads it on each render; that difference stays as the liveReducedMotion option.
2026-10-09 15:19:51 +02:00
Maurice b598b97865 refactor(planner): share the trip sheet wiring between desktop and phone
The phone trip sheet host wrote out by hand three pieces of planner
wiring the desktop planner already had.

useBookingExpenseEditor now holds the expense editor both planners open
over the trip: the booking request state, the receipt fallback, the base
currency, the current user and the save that reloads the budget.
TripPlannerPage and MTripSheets both read it.

applyTripCoverUpdate patches a new trip cover into the store for the
trip form on both sides.

The transport editor and the transit route change moved into
openTransportEditorWith and changeTransitRouteWith, which take the
planner's setters. usePlannerDialogs builds openTransportEditor and
changeTransitRoute on them, and the phone sheet calls the same helpers
on the planner it receives. There are no differences between the two
views to keep as options.
2026-10-09 15:19:51 +02:00
Maurice 973773e003 refactor(files): share the file filter, link list, note save and trash between desktop and phone
fileListRules now holds the type category of a file, the filter tab match
and the list of places and bookings a file is linked to (own column plus
extra links, transport or booking). useFileManager and FileManagerRow on
desktop and filesModel on the phone run on it. fileActions gains the field
save with its reload and assign error, which the desktop assign dialog and
the phone note edit both use, and the trash call with its toasts, used by
the desktop delete and the phone menu sheet. The phone sheet no longer
imports filesApi.

Differences that stay, each as an explicit option:
PHONE_FILE_FILTER_RULES sorts pdf, image and doc by type category (videos
count as images, doc is every file that is no PDF and no media, a pass
with a PDF mime type is a doc) and counts a note id of 0 as collab, while
desktop reads the mime type (images only, doc is Word, Excel and text).
DESKTOP_FILE_LINK_LIST_RULES skips an own place or booking column of 0,
the phone only a null one. PHONE_FILE_UPDATE_RULES waits for the reload
after a save, so a failed reload shows the assign error there; desktop
starts it and moves on. DESKTOP_FILE_TRASH_TOAST_RULES falls back to
English when the trash toast translation is empty.
2026-10-09 15:19:51 +02:00
Maurice 88a2479627 refactor(vacay): share the settings logic between desktop and phone
The holiday countries, the calendars split by type, the weekend day toggle, dissolving a fusion, the leave year save and window label, the region options and the add check now live in useVacaySettings. The phone sheet still loads countries only while open.
2026-10-09 15:19:50 +02:00
Maurice e4179dbc11 refactor(vacay): share the calendar sharing picker and row actions between desktop and phone
The read only share reuses useVacayUserPicker for picking and sending, and useVacayShareActions holds the hide toggle and the removal with their error toasts for the sidebar card, the phone sheet and the phone chips.
2026-10-09 15:19:50 +02:00
Maurice fd7f6f3cf9 refactor(vacay): share the fusion invite picker between desktop and phone
Loading the users that can be invited, the picked user and sending the invite with its toasts now live in useVacayUserPicker. The dialog still resets and closes on success, the sheet still closes and resets on the next open and empties the list when loading fails.
2026-10-09 15:19:50 +02:00
Maurice d0709c6a37 refactor(vacay): share the calendar logging logic between desktop and phone
The log mode and its half and comp modifiers, the entry, shared and company holiday maps, the trip dots, the day click rules, the default person and the day count format lived in both the desktop calendar and the phone screen. They now live in useVacayCalendarLogic and vacayFormat; the phone keeps reloading trip dots only on a new window shape.
2026-10-09 15:19:50 +02:00
Maurice 18eeb16aa0 refactor(dashboard): share the widget logic and plugin hooks between desktop and phone
The currency converter, the world clocks, the collection lists, the plugin widgets and card badges and the card date format each existed once per dashboard. They now live in useCurrencyConverter, useDashboardTimezones, useCollectionLists, useDashboardPlugins and dashboardModel; the phone keeps aborting a pending rate request through an option.
2026-10-09 15:19:50 +02:00
Maurice 080afa9cff fix(trips): keep the trip length across a DST change when the phone moves the start
The phone sheet added the old length with a local setDate on a UTC midnight date, so in zones east of UTC a range crossing the start of summer time came out one day short. It now counts UTC calendar days like the desktop dialog.
2026-10-09 15:19:50 +02:00
Maurice e964a98138 refactor(trips): share the trip form cover picker and checks between desktop and phone
The dialog and the phone sheet each carried the cover upload, the Unsplash search, the staged cover for a new trip and the title and range checks. They now live in useTripCoverPicker and tripFormModel; the blob url release keeps each view's timing behind an option.
2026-10-09 15:19:50 +02:00
Maurice 106830609e refactor(nav): share the context aware create action between the bottom navs
The centre button resolved its target in two copies of the same hook. useCreateAction now holds it once, with the phone only branches (gallery upload, atlas search, collections) behind a flag.
2026-10-09 15:19:50 +02:00
Maurice e1219a166a refactor(toast): share the toast queue between desktop and phone
Both presenters ran their own queue behind the window toast bus. The queue, the auto dismiss and the exit delay now live in useToastQueue; each presenter keeps its markup, its exit timing and its unmount handover.
2026-10-09 15:19:49 +02:00
Maurice 7261520659 refactor(journey): share the card carousel and the gallery upload between desktop and phone
useJourneyCarouselSync keeps the map on the card nearest the centre once
scrolling settles and brings a card to the centre for a marker, a day or a
tap, for both the phone journey screen and the narrow timeline of the desktop
and public pages. useGalleryUpload normalises and uploads gallery files for the
desktop gallery and the phone screen. The phone still moves the map on a tap
of another card, the desktop gallery still counts the files up while they go.
2026-10-09 15:19:49 +02:00
Maurice 8e95b4c7b6 refactor(journey): share the entry editor logic between desktop and phone
useJourneyEntryForm holds the fields, the photo queue that only goes out on
save, the gallery links, the location search and "use my location", the
verdict rows, quick capture and the save for both the editor dialog and the
entry sheet. journeyWeatherCategory moves to components/Journey/journeyWeather
so the dialog no longer reaches into mobile/. Where the two differed they still
do: the dialog starts with a blank verdict row, counts a moved point as a
change, toasts a failed save or location and fills the weather from the
forecast; the sheet edits tags and shows a refused location under the field.
2026-10-09 15:19:49 +02:00
Maurice 78ddb1b0b8 refactor(journey): share the journey settings logic between desktop and phone
useJourneySettings holds the name, cover, archive, the switches saved on the
spot, delete, unlinking a trip and removing a contributor for both the settings
dialog and the settings sheet. useJourneyTripLinking loads the linkable trips
and links one for the add trip dialog and the sheet, and useJourneyShareLink
manages the public share link for the share section and the sheet. The dialog
still closes after a new cover or an unlinked trip, the sheet still stays open.
2026-10-09 15:19:49 +02:00
Maurice e40bc98a33 refactor(atlas): share the country popup actions between desktop and phone
useAtlasCountryActions marks a country or a region, unmarks a region, adds the
country or region to the bucket list and takes the country off it again for
both the desktop dialog and the phone sheet. The desktop dialog moves into its
own component in AtlasPage so the page body keeps calling useAtlas only. The
phone sheet keeps storing a marked region as visited and its own month input;
the desktop keeps its month and year picker in useAtlas.
2026-10-09 15:19:49 +02:00
Maurice df6a8ba003 refactor(collections): share the saved place detail logic between desktop and phone
useCollectionPlaceForm reseeds the edit form when a different place opens,
fetches a cover photo for a place without one, uploads and removes the custom
cover and saves the edits for both the detail sheet and the phone sheet. The
detail keeps editing the coordinates and keeps a late photo across an update
of the same place; the sheet holds the last place through its exit and drops
a late photo once the place object changes, as before.
2026-10-09 15:19:49 +02:00
Maurice 771d4bbf07 refactor(collections): share the list editor logic between desktop and phone
useListEditor seeds the form from the target, handles the cover upload and the
Unsplash search, cleans the links and creates or updates the list for both the
dialog and the sheet. The sheet keeps holding the last list through its exit
and still hands over a list whose cover upload failed when it closes.
2026-10-09 15:19:49 +02:00
Maurice 634dd411c5 refactor(collections): share the copy to trip picker between desktop and phone
useTripCopyPicker loads the trips on every opening, filters them by the search,
formats the date range and turns the copy result into toasts for both the
dialog and the sheet. The dialog keeps its guard against an empty selection.
2026-10-09 15:19:49 +02:00
Maurice 1a4f986cc3 refactor(collections): share the list sharing logic between desktop and phone
useCollectionSharing holds the invitable users lookup, the roster order, invite,
role, cancel, remove and leave for both the share dialog and the share sheet.
The dialog keeps one busy member per kind of row action, the sheet one for all.
2026-10-09 15:19:48 +02:00
Maurice cf73779070 refactor(notifications): share the notification row actions between desktop and phone
Answering a question once, following a link after marking it read and the compact timestamp live in useNotificationItemActions. The desktop item keeps closing its panel after a navigation through the optional onClose.
2026-10-09 15:19:48 +02:00
Maurice a7bd26722a refactor(settings): share the general settings save between desktop and phone
Saving one preference with the error toast on failure lives in one useSettingSaver hook; the desktop tab drops its eleven inline copies of it.
2026-10-09 15:19:48 +02:00
Maurice 3b4f17643c refactor(settings): share the appearance editor between desktop and phone
The live preview, the debounced save with its flush on leave, the colour mode save and the widget toggles live in one useAppearanceEditor hook. The contrast helpers and widget groups move to appearanceModel.
2026-10-09 15:19:48 +02:00
Maurice 05394644b9 refactor(admin): share the default user settings logic between desktop and phone
Loading, saving and resetting the defaults for users and the map field handling live in one useDefaultUserSettings hook that reads the presets and style rules from mapSettingsModel. Each shell passes its own error text and preview stamp.
2026-10-09 15:19:48 +02:00
Maurice 0722a3b6ad refactor(settings): share the map settings form between desktop and phone
The tile presets, the provider and style rules and the preview place move to mapSettingsModel, the form state and save to one useMapSettingsForm hook. The phone keeps showing the normalized style before the save lands, through an option.
2026-10-09 15:19:48 +02:00
Maurice d042a18d0a refactor(settings): share the notification settings logic between desktop and phone
The preference matrix, the webhook and ntfy credentials and the channel test sends now live in one useNotificationSettings hook. The phone keeps sending the whole matrix on a toggle and skipping a token typed as the mask, through two options.
2026-10-09 15:19:48 +02:00
Maurice a8dc000e7e refactor(settings): share the account settings logic between desktop and phone
Profile and avatar, the password change, two factor setup with its backup codes and the account deletion now live in one useAccountSettings hook. Two existing differences stay as options: the toast key for a failed avatar removal and whether the backup code actions skip an empty code list.
2026-10-09 15:19:48 +02:00
Maurice 231acced7e refactor(settings): share the passkey logic between desktop and phone
Listing, adding with the step up and the WebAuthn ceremony, renaming and
deleting passkeys now live in one usePasskeys hook together with the
date and abort helpers. The desktop section and the phone card keep
their own markup.
2026-10-09 15:19:48 +02:00
Maurice 3b4539f600 refactor(settings): share the integration and plugin tab gating between desktop and phone
The addon flags, the managed install flag and the addon reload of the integrations tab now come from one useIntegrationGates hook, and useSettings hands both shells the plugins tab flag next to the integrations one.
2026-10-09 15:19:47 +02:00
Maurice deff306755 refactor(mcp): share the MCP configuration logic between desktop and phone
The OAuth presets, the endpoint snippets, the OAuth clients, sessions and API tokens with their handlers and the copy tick now live in one useMcpIntegration hook. Two existing differences stay as options: the desktop loads only while the addon is on, and the phone reads the client list back after a create or rotate where the desktop appends locally.
2026-10-09 15:19:47 +02:00
Maurice 5ee7cc052c refactor(plugins): share the plugin user settings, OAuth and activity logic between desktop and phone
The declared field form with its save, the actions and their confirm state, the OAuth connect and disconnect and the activity log now live in usePluginUserSettings, usePluginOAuth and usePluginActivity. Each shell keeps its own form, confirm dialog or sheet and picker.
2026-10-09 15:19:47 +02:00
Maurice 2acfc9cb43 refactor(memories): share the photo provider connection cards between desktop and phone
The provider field model, the seeding and hydration, save and test now live in one usePhotoProviderConnections hook. Two existing differences stay as options: on the phone the status route alone sets the badge, and the desktop keeps its space before the server reason in a failed test toast.
2026-10-09 15:19:47 +02:00
Maurice 82070c72ec refactor(oauth): share the scope group picker logic between desktop and phone
The grouped scopes, the folded groups and the select all, group and single scope toggles now live in one useScopeSelection hook. Both pickers keep their own rows.
2026-10-09 15:19:47 +02:00
Maurice 309753b799 refactor(settings): share the AI parsing form between desktop and phone
The provider, model, key and multimodal state, the hydration from the settings store and the save with its reload on failure now live in one useLlmConnection hook. The phone keeps its picker sheet state.
2026-10-09 15:19:47 +02:00
Maurice 68e2e0639b refactor(airtrail): share the AirTrail connection form between desktop and phone
Both settings shells now read their state, the save and test handlers and the canSave rule from one useAirTrailSettings hook and keep only their own markup.
2026-10-09 15:19:47 +02:00
Maurice 6c5ab5414a refactor(admin): share the JWT rotation and passkey reset between desktop and phone
useAdminUserActions now rotates the JWT secret and resets a user's passkeys for the desktop dialogs and the phone sheets. Each shell keeps its own confirm step, and the phone sheet still stays busy until the reset settles.
2026-10-09 15:19:47 +02:00
Maurice feb99ce398 refactor(admin): share the admin settings saves and the TREK API lists between desktop and phone
useAdminSettingsActions now holds the OIDC and file type saves and the five place switches, writing through the admin bag both shells already receive. trekApiModel keeps the TREK Places API fields, facts and sources the desktop card and the phone block list.
2026-10-09 15:19:46 +02:00
Maurice 8c8069e983 refactor(admin): share the admin shell actions between desktop and phone
Saving the demo baseline and the bag tracking and collab feature switches now live in useAdmin. AdminPage and the phone admin screen pass them on instead of carrying their own inline copies.
2026-10-09 15:19:46 +02:00
Maurice 7738334fd6 refactor(admin): share the addon manager and the AI parsing config between desktop and phone
useAddonManager now loads and toggles the addons and builds the provider shelves, useLlmParsingConfig holds the AI parsing form with the Ollama model list and pull, and addonModel keeps the shared types, collab sub features, labels and recommended models. Both shells keep their own markup.
2026-10-09 15:19:46 +02:00
Maurice 52461a6cd8 fix(admin): stop a stale base URL from reaching the Anthropic config on the phone
The phone AI parsing form saved the base URL left over from a Local or OpenAI setup after switching to Anthropic, so Anthropic requests went to the old host. It now saves an empty base URL for Anthropic, as the desktop form does since #1777.
2026-10-09 15:19:46 +02:00
Maurice 2a242f29fb fix(admin): keep a parallel addon toggle when a photo provider toggle fails on the phone
The phone rolled a failed photo provider toggle back by restoring the whole addon list it saw at the click, which also undid any switch flipped while the request was in flight. Photo providers now go through the same toggle as every other addon, with a per row rollback, as on desktop.
2026-10-09 15:19:46 +02:00
Maurice 163b4c54e0 refactor(admin): share the notification settings saves and tests between desktop and phone
useAdminNotificationSettings now holds the SMTP save and test, the trip reminder switch and the admin webhook and ntfy save, test and token clear. The desktop tab drops its inline handlers and both views keep their markup.
2026-10-09 15:19:46 +02:00
Maurice ab3a69e18c refactor(admin): share the admin notification matrix between desktop and phone
useAdminNotificationMatrix now loads the matrix, picks the visible built in channels and saves each toggle with a per cell revert. The desktop panel and the phone card keep their own markup.
2026-10-09 15:19:46 +02:00
Maurice 2acef40c35 fix(admin): keep a later notification toggle when an earlier one fails on the phone
When a toggle on the phone matrix failed after a later toggle had already rendered, its catch put back the preferences from its own click and so also undid the later cell. The live preferences are now mirrored in a ref and a failure reverts only its own cell, as the desktop panel already does. The test hangs the first save, lets the second one through and then fails the first.
2026-10-09 15:19:46 +02:00
Maurice 78fa42a0e6 refactor(admin): share the backup panel logic between desktop and phone
useBackupAdmin now loads the backups and the auto backup schedule and runs create, download, delete, restore and the schedule save. The phone keeps its form when the server answers without settings through the keepSettingsWhenMissing option.
2026-10-09 15:19:45 +02:00
Maurice f1719c3e35 refactor(admin): share the packing template manager logic between desktop and phone
usePackingTemplates now owns the template list, the expanded template with its categories and items, and every create, rename and delete. The phone keeps its generic delete error toasts through the genericDeleteErrors option.
2026-10-09 15:19:45 +02:00
Maurice 7da3a1724c refactor(admin): share the MCP token and OAuth session admin between desktop and phone
useMcpTokensAdmin now loads both lists, revokes sessions, deletes tokens and keeps the expanded scope chips. Both panels keep their own cards and confirm dialogs.
2026-10-09 15:19:45 +02:00
Maurice 7c006b6445 refactor(admin): share the category manager logic between desktop and phone
useCategoryAdmin now holds the category list, the create and edit form, the colour presets and the delete. The phone sheet still shows a busy delete and closes itself on success through the trackDelete option.
2026-10-09 15:19:45 +02:00
Maurice 0de89b86fa refactor(admin): share the permission levels editor between desktop and phone
Loading, editing, resetting and saving the permission levels now live in one usePermissionsAdmin hook next to the category and level tables. Both panels keep their own markup.
2026-10-09 15:19:45 +02:00
Maurice a52179f685 refactor(admin): share the audit log loading and paging between desktop and phone
Both audit log panels now read one useAuditLog hook for the first page, refresh, load more and the row labels. The desktop table and the phone cards keep their own markup.
2026-10-09 15:19:45 +02:00
Maurice 40fa8c6fe6 refactor(plugins): share the plugin admin logic between desktop and phone
Both panels carried the same types, helpers and a four hundred line block of state and handlers. pluginModel now holds the types and pure helpers, usePluginsAdmin the state, loading and every action, and usePluginDetail the registry detail fetch. The phone keeps starting all updates at once from Update all and reads the detail manifest under its stricter shape; both panels keep their own markup, dialogs and sheets.
2026-10-09 15:19:45 +02:00
Maurice a99b0c5b32 refactor(storage): share the storage panel and backend editor logic between desktop and phone
useStoragePanel holds the editor, removal and sync prompts, the save with its migrate prompt and the sequential migration queue; useBackendForm holds the backend draft, its checks and the backend it builds. The desktop keeps dropping the rest of the queue when a migration fails to start, and keeps recomputing the candidates at confirm time; the phone keeps moving the candidates its prompt opened with.
2026-10-09 15:19:45 +02:00
Maurice e92b233c3a fix(admin): the phone release history no longer skips a page after a long prerelease run
When five full pages in a row held only prereleases, the walk stopped with its page counter already one past the last page it read, so the next Load more started a page later and that page of releases never showed. The counter now ends on the last page read.
2026-10-09 15:19:44 +02:00
Maurice 81decb843d refactor(admin): share the GitHub release history between desktop and phone
useGithubReleases holds the paged fetch, the prerelease filter, the expanded rows and the date format, and parseReleaseNotes splits a release body into the blocks both panels draw. The phone keeps its page walk past hidden prereleases through the fillPages option, its repository link and its own inline link scan; the desktop keeps one page per load.
2026-10-09 15:19:44 +02:00
Maurice 67f20829f7 refactor(admin): share the dev notification tester between desktop and phone
Both panels loaded the same trips and users, built the same twelve test payloads and sent them the same way. That now lives in useDevNotifications; each panel keeps its own markup and the phone keeps its own wording of the failure toast.
2026-10-09 15:19:44 +02:00
Maurice 5abe5f6519 refactor(docsync): move the sync direction type into the docsync model
The pure model imported its direction type from the flow view. The type
now lives in the model, which takes it as the direction parameter, and
the flow view re-exports it for its existing importers.
2026-10-09 15:19:44 +02:00
Maurice f190aa5a01 refactor(collab): format the moved collab model and drop its stale comments
The model left the format baseline when it moved into components/Collab,
so it is now Prettier formatted. Its header no longer points at the
deleted services layer, and the colour palette no longer cites a rule
that stopped applying once the file sat beside the desktop panel. The
chat data hook says why it still returns setters nobody writes.
2026-10-09 15:19:44 +02:00
Maurice c9c3cc6175 refactor(files): keep each view's id rules in the shared link toggle
The shared toggle had mixed the two views. Desktop again treats any empty
column as free and finds a link record by comparing ids as they come. The
phone link sheet again treats only a null column as free and compares the
numeric value of a record id.

Starring stays per view. It is a single filesApi call with different
error handling and refresh on each side, so a shared wrapper would add a
layer without removing any logic.
2026-10-09 15:19:44 +02:00
Maurice a809de1b7a refactor(collab): keep the phone notes tab loading without a trip id guard
The shared notes hook skipped loading and listening without a trip id,
which only the desktop panel did. That guard is now a desktop option, so
the phone tab loads and listens as before.
2026-10-09 15:19:44 +02:00
Maurice 1c88a42890 refactor(collab): keep the phone's own poll reconciliation in the shared hook
The shared polls hook had taken the desktop rules for both views. It now
keeps each view's logic as it was before the move. The phone again
replaces a poll with the one a live closed event carries, puts a vote
answer on the poll it voted on, and stops the countdown tick once a
deadline passed. The desktop panel again ticks until a poll is closed.
2026-10-09 15:19:44 +02:00
Maurice 74e1853c39 refactor(docsync): share the scope picker, lane switch and folder name between desktop and phone
useScopePicker holds the provider's containers, binding one, and making
and binding a new one under the suggested name; the desktop dialog keeps
its search filter. docSyncModel holds the direction a lane switch leads to
and the folder name made from the trip title, which the panel and the
phone sheet each carried a copy of.
2026-10-09 15:19:43 +02:00
Maurice dd7cfca0b2 refactor(collab): share the notes list and attachment upload between desktop and phone
useCollabNotesData holds the note list with its first load and the live
note events, and the attachment upload both shells run after saving a
note. Writing notes, their colours and categories stays in each shell,
since the desktop panel keeps empty categories and colour batches the
phone tab does not have. A failed load still empties the desktop list only.
2026-10-09 15:19:43 +02:00
Maurice 428b4e6006 refactor(collab): share the polls logic between desktop and phone
useCollabPolls holds the poll list with its live updates, the countdown
tick, and creating, voting, closing and deleting. The desktop panel reads
the vote totals, winner and closed state from collabModel like the phone
tab does and keeps its own countdown label. A failed load still empties the
desktop list only, and closing still takes the server poll on the phone
only.
2026-10-09 15:19:43 +02:00
Maurice 7298285a43 refactor(collab): share the chat logic between desktop and phone
collabModel moves from the phone tabs into components/Collab so both
shells read it, and the desktop chat drops its own copies of the time,
date separator and emoji only helpers for the model's.

useCollabChatData holds the messages, paging, live updates, the draft with
its images, and sending, deleting and reacting. The desktop hook keeps its
emoji picker, hover and delete animation, the phone tab its long press
popover; where they differed (older page errors, scrolling after a remote
delete, upload progress, the edit permission on send) the hook takes an
option.
2026-10-09 15:19:43 +02:00
Maurice 676de4c1d9 refactor(files): share the media lightbox logic between desktop and phone
useMediaLightbox holds the signed image address of the file on screen,
paging by button, arrow keys and swipe, and Escape to close. The desktop
lightbox keeps its own index and thumbnail strip, the phone lightbox stays
controlled by the files tab; both draw their own overlay over the hook.
2026-10-09 15:19:43 +02:00
Maurice 076ed897e7 refactor(files): share the file trash, paste and link rules between desktop and phone
useFileTrash holds the trashed files with their restore, permanent delete
and empty actions; the desktop trash view loads it on demand and the phone
sheet while it is open. fileActions holds the clipboard file picking and the
two tier link toggle (own column first, then link records) that the desktop
assign dialog and the phone link sheet both ran on their own.
2026-10-09 15:19:43 +02:00
Maurice 000c9dfa30 refactor(roadtrip): share the add stop draft between desktop and phone
The stop popup and the phone draft sheet take the kind and length a found stop opens with, the kind pick that brings its usual length along and the six length choices from useStopDraftForm and stopKinds. The desktop keeps its night booking and still starts from the draft it mounts with; the phone still seeds the answers for every draft it is handed.
2026-10-09 15:19:43 +02:00
Maurice 745cd5f65d refactor(roadtrip): share the stay length draft between desktop and phone
The stay dialog and the phone stay sheet take their presets, the five minute step, the full day limit, the draft and the arrival and departure preview from useStayDraft. Each still seeds the draft when it opens and saves it its own way; the phone keeps whole minutes when a step moves a stored fraction, the desktop does not.
2026-10-09 15:19:43 +02:00
Maurice 97d3512aa7 refactor(planner): share the trip export between desktop and phone
The export dialog and the phone export sheet keep their rows and run the PDF, the calendar file and the GPX download through useTripExport, which also reads the print settings and whether my plan is offered. The desktop still runs one export at a time and closes after each one; the phone still lets a calendar or GPX file run beside a PDF and stays open after the PDF.
2026-10-09 15:19:43 +02:00
Maurice a4e3e83d3f refactor(planner): share the transport detail day and segment labels between desktop and phone
The transport detail dialog and the phone transport sheet name the booking's day and each segment's own booking code through transportDetailModel, next to the files filter they already share. The rest of the two views reads the booking differently on purpose and stays as it is.
2026-10-09 15:19:42 +02:00
Maurice fc3cc1bbbe refactor(planner): share the booking form and its hotel stay between desktop and phone
The booking dialog and the phone booking sheet keep their markup and run on useReservationForm: how a saved booking or an import fills the form, the end before start check, the start date and time, the place and hotel picks, the stay range and the request body with the stay a hotel booking creates come from reservationFormModel. The desktop's check-in window, its link to a stop inside the form and its imported price stay its own, behind options where the code is shared.
2026-10-09 15:19:42 +02:00
Maurice 203e425e7e refactor(planner): share the transport booking form between desktop and phone
The transport dialog and the phone transport sheet keep their markup and run on useTransportForm: the route rows, how a saved booking or an import fills them, the day options and the request body come from transportEndpoints. Where the two shells differ today (unknown type fallback, stopover days, which rows are reset, train anchors, empty metadata, the link field) the shared code takes an option. The phone's upload helper moves to bookingFormModel next to the traveller checks both booking forms use.
2026-10-09 15:19:42 +02:00
Maurice 131d57b755 refactor(budget): share the costs ledger and settlement form between desktop and phone
costsModel moves from the phone tabs to components/Budget.
useCostsLedger holds the settlement, the totals, the filters, the CSV
export and the payments, and useSettlementForm holds the payment form.
CostsPanel and MCostsTab both run on them.

The old differences stay behind flags. The desktop also loads the
settlement on a trip change, leaves expenses nobody paid out of my share
(#2225) and writes each currency as stored in the CSV. The phone sheet
starts blank, refills when it opens and ignores a second save while one
is running.
2026-10-09 15:19:42 +02:00
Maurice 80c66d3f83 refactor(budget): share the expense editor between desktop and phone
useExpenseForm holds the payers, the splits, the ticket items and the
receipts of the expense editor in CostsPanel and MCostSheet.

Flags keep the old differences. Only the desktop sends the people named
on a ticket line as members (#1382). The phone ignores a second save
while one is running and stays busy after a successful save. The
rebalance effect uses useEffectEvent instead of an eslint-disable.
2026-10-09 15:19:42 +02:00
Maurice ad220d9608 refactor(packing): share the bag editor and the bag weight totals between desktop and phone
useBagCardEditor holds the editing of a bag's name and weight limit for
the desktop bag card and MBagsSheet, and bagLoadSummary computes the
weight totals for the bag sidebar, the bag modal and the phone sheet.
The desktop card follows the bag when it changes elsewhere, the phone
row resets a name it did not save.
2026-10-09 15:19:42 +02:00
Maurice 4d4ef17de8 refactor(packing): share the packing list logic between desktop and phone
usePackingItemActions, usePackingTemplates and usePackingImport take the
item, template and bulk import logic out of usePackingListPanel and
MPackingListTab, and the phone listsModel keeps only its priority
tokens.

When adding to a category the desktop looks for the placeholder in the
store and the phone in its rendered items. The desktop toasts on an
empty import, the phone runs one import at a time. The effects run in
the same order as before.
2026-10-09 15:19:41 +02:00
Maurice 6dc8699bd1 refactor(todo): share the to-do filters and the task form between desktop and phone
The to-do filters, counts, sort order and categories move from the phone
listsModel into todoListModel. useTaskForm holds the name, the fields,
the load and the save of a task for the desktop detail and new task
panes and for MTaskSheet. Each side keeps its own guard and error
wording.
2026-10-09 15:19:41 +02:00
Maurice 5a0e03929d refactor(lists): share the remembered lists sub-tab between desktop and phone
useListsSubTab remembers whether the packing or the to-do sub tab was
open, for ListsContainer and MTripShell. The desktop restores any stored
value, the phone restores todo and otherwise falls back to packing.
2026-10-09 15:19:41 +02:00
Maurice 71289ba43e refactor(places): share the place form and the maps search between desktop and phone
usePlaceForm and usePlaceSearch take the form state, the prefill, the
opening hours selection, the duplicate name check, the payload and the
maps search out of PlaceFormModal. MPlaceEditSheet, PlPlaceSearch and
PlCategoryPicker run on the same hooks, and PlTimeFields uses the shared
timeCollisions.

Where the two shells behaved differently the hooks take a dialog or a
sheet variant: the coordinate pair rule, abort and console handling,
when the billing session ends, whether a pick clears the suggestions,
the empty search cue, whether a map link clears the results, and how a
new category is trimmed and guarded. The open effect and the submit flow
stay per shell, because service stops, expense intents and delete arming
differ too much to share.
2026-10-09 15:19:41 +02:00
Maurice b2fd0ae5aa refactor(collections): share the add place to list logic between desktop and phone
useAddPlaceToCollection holds the maps search, the pick and the save
that AddPlaceToCollectionModal and MCollAddSheet each carried, with the
pure helpers in addPlaceModel.

The differences stay as variants. The desktop resets and stays open
after an add, sends links and typed coordinates and shows the error's
own text. The phone closes, takes coordinates only from the picked hit,
ignores a second search or save while one is running and shows the
fallback text.
2026-10-09 15:19:41 +02:00
Maurice bbe3da7722 refactor(places): share the places pool filters and selection between desktop and phone
usePlacesPool holds the category filter, the search, the multi select
and the bulk mark visited of the places pool, for usePlacesSidebar and
for MPlacesBrowser with its filter controls and sheet. When selected
places are deleted, the desktop leaves select mode once none of them is
left and the phone drops only the missing ids. The hook keeps both.
2026-10-09 15:19:41 +02:00
Maurice 4dc446424f refactor(places): share the GPX, KML and KMZ file import between desktop and phone
usePlacesFileImport holds the GPX, KML and KMZ import that
FileImportModal and ImpFileStep each carried. The desktop keeps one
toast and one undo label and counts only the summary's skips. The phone
keeps a toast per format, the importFiles undo label for a mix and the
top level skipped fallback.
2026-10-09 15:19:41 +02:00
Maurice f2b6a14e9f refactor(places): share the Google and Naver list import between desktop and phone
useListImport holds the Google and Naver shared list import that
usePlacesSidebar and ImpListStep each carried. The desktop closes the
dialog and clears the link before the undo step, the phone calls onDone
after it, and the hook keeps both orders.
2026-10-09 15:19:41 +02:00
Maurice 8a28f5e22f refactor(collections): share the bulk save to list logic between desktop and phone
useSaveTripPlacesToList holds the logic that saves a selection of trip
places to a list, which SaveTripPlacesToListModal and
MPlacesSaveToCollectionSheet each carried as a copy. The two copies did
the same thing, so the hook has no variants.
2026-10-09 15:19:40 +02:00
Maurice 1b51181930 refactor(planner): share the bookings and transports list filters between desktop and phone
The bookings model moves from the phone tabs into components/Planner/bookings, so
both shells read the same sections, traveller predicate and type colours. The
phone bookings and transports tabs had the same traveller filter and folded
sections twice; that state now lives once in useReservationListFilter, which only
those two tabs use. The desktop panel keeps its own filters in useBookingsView
because it remembers them for the session.

The status switch and the delete on a booking card stay per shell. The desktop
hook renders its ConfirmDialog and awaits the delete, the phone asks in an
MConfirmSheet and lets the delete run on its own; sharing them would change
when the question closes.
2026-10-09 15:19:40 +02:00
Maurice 9529b55bd7 refactor(planner): share the booking files filter between desktop and phone
filesFor moves out of the desktop booking parts into utils, and the phone
transport sheet now reads a booking's files through it as the desktop
transport detail and booking cards do.
2026-10-09 07:16:40 +00:00
Maurice d01d9df93a refactor(planner): share the day note draft and save between desktop and phone
The desktop note dialog and the phone note sheet fill their editor with
dayNoteDraft and save through writeDayNote, so both trim the title, store an
empty detail as null and default the icon the same way. Both count the detail
line against one shared limit.
2026-10-09 07:16:40 +00:00
Maurice 70c96be193 refactor(planner): share the stay editor rules between desktop and phone
The desktop hotel picker and the phone accommodation sheet take their day
options, range changes, checkout default, places on offer and request body
from stayFormModel, and both save a stay through writeStay.
2026-10-09 07:16:40 +00:00
Maurice f6d51ba68e refactor(planner): share the day detail logic between desktop and phone
The desktop day panel and the phone day sheet load the day's forecast with
useDayForecast, rename the day with useDayRename, and read the day's
bookings, the stay labels and the display temperature from dayDetailModel.
2026-10-09 07:16:40 +00:00
Maurice 38c7d76c5d refactor(planner): share the per leg travel mode between desktop and phone
The day plan's connector menu and the phone timeline's are built by
legModeMenuItems, and both save a leg's mode through useLegModeActions, which
reports a refused write and reloads the days the same way on both.
2026-10-09 07:16:40 +00:00
Maurice 20d7e62afc refactor(planner): share the day plan reorder writes between desktop and phone
The desktop day plan and the phone timeline derive the place order, the note
and transport positions and the per leg positions of a reordered day from
mergedOrder, and write the moved bookings back through the same helpers.
2026-10-09 07:16:40 +00:00
Maurice ae31b55441 refactor(planner): share the day route export and optimize slots between desktop and phone
dayRoute moves from the phone's lib into components/Planner. The desktop day
plan builds its Google Maps and CoMaps stops with dayExportStops, and both
optimizers put the reordered stops around the fixed ones with fillAroundLocked.
2026-10-09 07:16:40 +00:00
Maurice 9f3e1f8c83 refactor(weather): share the day weather anchor and icon map between desktop and phone
The day plan, the day panel, the phone timeline and the phone day sheet read
the day local weather anchor from dayWeatherAnchor, and every weather icon
comes from one map in weatherIcons.
2026-10-09 07:16:40 +00:00
Maurice 34127b99bb refactor(planner): share the place detail actions between desktop and phone
The place inspector and the phone place sheet now run on one module for
their participants, file uploads, image picks, ratings and place updates,
and both build the save to collection target with placeToSaveTarget.
2026-10-09 07:16:40 +00:00
Maurice 8bd831238d test(server): cover the lease failures, sink errors and store passthroughs
The lease heartbeat and settle failures, the log sink and rotation
fallbacks, the runtime env getters, the public API summary edges, the
admin-only permission level and the WebSocket handler rejection had no
spec, which took six domains under their coverage floor.
2026-10-08 21:15:17 +02:00
Maurice 2b218a5528 refactor(server): drop the state helpers the injected stores replaced
setPermissionsCache and the free leaveRoom, leaveAllRooms and
leaveAllBooks lost their last caller when the services moved onto the
injected stores.
2026-10-08 21:15:17 +02:00
Maurice 9c39a0fc58 test(offline): count the trip member the v2 case stores 2026-10-08 20:22:17 +02:00
Maurice da97d7535f test(server): follow the signatures the session and port work changed
The session gate passes a session lookup to verifyJwtAndLoadUser, the
Immich save takes auto_upload, getAppUrl stays synchronous, the demo
session list marks the current session by the id it is handed, and the
lease no longer needs a transaction of its own, so its spec is gone.
2026-10-08 20:22:16 +02:00
Maurice 37af6f4ff9 test(server): seed bookings and places the way the services store them
The converted specs wrote accommodation_id as a plain id, while the
services store the legacy '<id>.0' text, and wrote read-only foreign key
twins instead of their relations. The files e2e schema gains the trip
columns the entity fills, and the idempotency spec seeds a real user.
2026-10-08 20:22:16 +02:00
Maurice ca0138dc45 test(server): make the row factories return what they promise
findRow and the default category lookup asked findOne with an empty
where, which MikroORM refuses. insertRows returned a single id because
insertMany on plain data reports only the last insert id; it now inserts
row by row and returns every id. insertRow and updateRows reject instead
of throwing before the caller holds a promise.
2026-10-08 20:22:16 +02:00
Maurice 7ae4df1ca2 test(trips): expect a failed day rebuild to roll the new dates back
TRIP-SVC-072 still pinned the old order, where the trip row committed
before the days were rebuilt. Both now run in one transaction, as
TRIP-SVC-018b already asserts.
2026-10-08 19:21:52 +02:00
Maurice 0172c5d12a ci(server): record the first Postgres probe measurement
All 100 dialect helper cases pass on both engines. Of 1402 repository
methods, 11 send one statement Postgres refuses as SQL and 4 send none
the probe can see; both lists may only shrink from here.
2026-10-08 19:19:15 +02:00
Maurice ce56efefc6 docs(wiki): list the dialect, Kysely type and Postgres probe commands 2026-10-08 19:18:42 +02:00
Maurice 0fa47e4297 test(demo): give the session reset spec a baseline to reset to
resetDemoUser returns early when no baseline file exists and asks the
port whether it can snapshot, so the spec needs both to reach the swap.
2026-10-08 19:18:42 +02:00
Maurice be016f180e test(server): seed the session, lease and rollback specs through the factories
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
2026-10-08 19:05:04 +02:00
Maurice ccf8c4309a ci(server): check the generated Kysely types and the SQL dialect baseline
check:db-types and lint:dialect were documented as gates but not run.
2026-10-08 19:05:04 +02:00
Maurice 8c66964961 test(server): fit the session, transaction and seeding specs together
The session carry-over specs were written against the old close and
reinitialize calls; they now drive the database backup port. The rollback
specs read through the factories and await the helpers that became async
when the seeding moved to the ORM. Two readers that lost their await and a
duplicated import are fixed as well.
2026-10-08 19:05:04 +02:00
Maurice 68504b337f refactor(scheduling): take a cron lease in one conditional upsert
acquire() made sure a row existed and then raced on a conditional UPDATE,
two statements the registrar wrapped in a transaction of its own. lint:tx
cannot follow a repository reached through a local variable, so it
reported the method. One INSERT ... ON CONFLICT DO UPDATE ... WHERE now
takes a free lease, renews the holder's and leaves anyone else's alone,
atomically on SQLite and on Postgres, and needs no transaction.
2026-10-08 19:05:03 +02:00
Maurice fac139723e build(db): generate the Kysely types for the lease and session tables
Both tables arrived in migrations written before the generator existed,
so src/db/kysely/ did not describe them and check:db-types would fail.
Regenerated with gen:db-types.
2026-10-08 19:05:03 +02:00
Maurice 115f76ffae style(server): re-indent the user deletion assertions in the admin spec
The conversion left 23 assertions of the user deletion case at column 0 in the middle of its indented body.
2026-10-08 18:47:25 +02:00
Maurice e90ed065e5 test(server): clear and age fixture rows through the ORM in converted specs
These specs left the raw SQL baseline but still emptied tables with
`db.exec('DELETE FROM ...')` and aged a Dawarich connection with a raw
UPDATE, which the ratchet cannot see because it only counts `.prepare(`.
They now go through deleteRows and updateRows like the rest of the file.

packing_item_recipients and packing_bag_members have no entity; both
cascade from their item and bag, so the packing spec relies on that with
foreign keys on in the snapshot database.
2026-10-08 18:47:25 +02:00
Maurice df10dc6ec2 test(server): assert the row exists before checking its stamped columns
findRow returns null for a missing row, and `row?.deleted_at` is then
undefined, which `.not.toBeNull()` accepts. The converted soft delete,
album sync and passkey counter checks would therefore pass if the row were
removed outright. They now assert the row is there first, as the raw
`.get()` reads they replace did by throwing on a missing row.
2026-10-08 18:47:25 +02:00
Maurice f579f03692 test(server): drop the dashes from two reworded spec comments
The permissions and plugin RPC fallback comments were reworded when
their raw SQL went away; this keeps the new wording in line with the
comment style used elsewhere in the converted specs.
2026-10-08 18:47:24 +02:00
Maurice 4c69804c81 test(server): lower the raw SQL baseline to what the converted specs hold
The specs converted to the factories no longer carry raw prepare()
calls, or keep only ones marked with test-sql-allow, so their baseline
entries are dropped. Leaving them in place would let raw SQL grow back
into those files unnoticed.
2026-10-08 18:47:24 +02:00
Maurice 6a7b55b13b test(server): seed the reservations service spec through the factories
Converts the raw prepare() fixtures and reads in the reservations service
spec to the ORM factories: expense links, stays, stops, endpoints, day
positions and guest flags are written through insertRow and updateRows,
and the narrow SELECT post-conditions read whole rows through findRow and
findRows, compared with toMatchObject so they still assert exactly the
columns the projection named. stopsOn keeps returning only the three
columns its toEqual assertions compare, and the helpers that now await
become async.

The one write left raw carries a test-sql-allow marker: it plants
dangling references inside the synchronous foreign_keys = OFF window,
which an async ORM write would outlive. The unused canAccessTrip and
isOwner entries are dropped from the database mock.
2026-10-08 18:47:21 +02:00
Maurice 24a2624488 test(server): seed the users repository spec through the factories
Converts the raw prepare() fixtures and post-condition reads in the users
repository spec to updateRows, findRow and readUser. The Immich read
helper becomes async and still returns only the three columns its toEqual
assertions compare, and the guest insert check compares its columns with
toMatchObject now that the read returns the whole row.

The raw statements that remain carry a test-sql-allow marker: the legacy
statements run as parity oracles for the repository reads, the
NOT NULL DEFAULT 0 probe on is_guest, which has to bypass the entity
default, and the unflushed-entity check, which must read the table
without an EntityManager that could flush first.
2026-10-08 18:47:13 +02:00
Maurice a0a584e1df test(server): seed the places service spec through the factories
Converts the raw prepare() fixtures and reads in the places service spec
to the ORM factories: tours, waypoints, files, links, expenses, collection
places and ratings are written through insertRow and updateRows, and what
the service left behind is read back through findRow, findRows and
countRows. The track colour cases insert their seeded tracks with
insertRows in place of INSERT ... RETURNING, tracksOf and the file attach
helper become async, and the stale updated_at is computed with dbNow
instead of datetime('now', '-1 hour'). The rating reads keep comparing
only the rating column, as the narrow SELECT did.
2026-10-08 18:47:12 +02:00
Maurice 40c5eb5c54 test(server): seed the calendar service spec through the factories
Converts the raw prepare() fixtures in the calendar service spec to the
ORM factories: reservation times, metadata and endpoints, stays, stops and
place addresses are written through insertRow and updateRows. The stay
helpers become async, so their callers await them. The timezone cache
case seeds its 1010 bulk bookings and their endpoints with two insertRows
batches instead of a raw transaction. The unused canAccessTrip and
isOwner entries are dropped from the database mock.
2026-10-08 18:47:12 +02:00
Maurice 6c3bb58d59 test(server): seed the auth service spec through the factories
Converts the raw prepare() fixtures and reads in the auth service spec to
the ORM factories: instance settings go through setAppSetting and
deleteRows, MFA, OIDC and invite state through updateRows, and the
password version, push device and MCP token checks read back through
readUser and countRows. The push device helpers become async, so every
caller now awaits them. The unused canAccessTrip and isOwner entries are
dropped from the database mock, since nothing under test imports them.
2026-10-08 18:47:12 +02:00
Maurice 61598e070a test(server): seed the admin integration suite through the factories
Converts the raw prepare() fixtures and reads in the admin integration
suite to the ORM factories and readers. The user-deletion case that seeds
one row per foreign key pointing at the departing user now writes each of
them through insertRow and checks what survived through findRow; the
expiry timestamps that used datetime('now', ...) are computed with dbNow.
The OAuth client in that case uses the same string for its row id and its
public client_id, so the token and consent rows reference it the same way
whichever column the relation keys on.
2026-10-08 18:47:12 +02:00
Maurice cfa3d10dfa test(server): seed the trips integration suite through the factories
Converts the raw prepare() fixtures and reads in the trips integration
suite to the ORM factories and readers: archived trips, permission rows,
memberships, the copy's source rows (tagged places, assignments, to-dos,
the budget category order, private packing items) and every read of what a
request left behind go through insertRow, updateRows, findRow and findRows.
The copied place tags are counted per place through the place_tags
relation instead of a raw join.
2026-10-08 18:47:12 +02:00
Maurice 732b951439 test(server): seed the document sync service spec through the factories
Converts the raw prepare() fixtures and reads in the document sync service
spec to the ORM factories and readers. The row builders (bindings, sync
items, files, the connection) and the files service double write through
insertRow and updateRows and are async now, the shape the real service has;
the row readers go through findRow and findRows. Seeding that ran inside
Array.from callbacks is a plain loop, so every row is written before the
run it feeds starts.

Three reads stay raw behind allow markers: the helper that takes a time
from SQLite's own clock and the two checks that compare a stored timestamp
with CURRENT_TIMESTAMP and julianday('now'), since the clock is what they
are about.
2026-10-08 18:47:12 +02:00
Maurice e88e28b79c test(server): seed the user cleanup service spec through the factories
Converts the raw prepare() fixtures and reads in the user cleanup service
spec to the ORM factories and readers: plugins and their per-user config,
journeys with their entries, photos, contributors and share links, trip
share tokens, settlements and memberships are written through insertRow and
makeShareToken, and what the deletion leaves behind is checked through
findRow and countRows. The slim-schema case keeps its single raw users
insert behind an allow marker: that database is hand-rolled and carries no
schema an entity could write to. The trek photo the journey case seeds now
takes its created_at from the column default instead of a literal 0 no
assertion reads.
2026-10-08 18:47:11 +02:00
Maurice 56060188a6 test(server): seed the atlas service spec through the factories
Converts the raw prepare() fixtures and reads in the atlas service spec to
the ORM factories and readers: places, booking endpoints, travellers,
visited countries and regions, cached place regions and bucket list rows
go through makePlace, makeReservationEndpoint, insertRow and upsertRow, and
the region cache is read back through findRow.

The two #2527 cases keep their raw move and delete, with an allow marker:
they land inside the repository call the test intercepts, synchronously on
the call's own connection, which is the race they pin. The database mock
loses its raw canAccessTrip and isOwner, which nothing reads any more.
2026-10-08 18:46:52 +02:00
Maurice efce09f3a2 test(server): seed the memories access service spec through the factories
Converts the raw prepare() fixtures and reads in the memories access
service spec to the ORM factories and readers: shared trip photos, journey
photos and contributors, album links and the trip memberships the access
checks walk are written through insertRow. The legacy MA5 statement stays
raw behind an allow marker as the oracle its parity cases compare with, and
the database mock loses the raw canAccessTrip and isOwner nothing reads.
2026-10-08 18:46:52 +02:00
Maurice 234400b2fe test(server): seed the reservation visibility parity spec through the factories
The reservation visibility parity harness now stages bookings, links stays
and reads the trip's days through the ORM factories and readers. The
legacy string fragments it holds the typed predicates to keep running raw
behind allow markers, since they are the fixed side of the comparison.
2026-10-08 18:46:52 +02:00
Maurice 7427a47eea test(server): seed the collab service spec through the factories
Converts the raw prepare() fixtures and reads in the collab service spec to
the ORM factories and readers: reactions, note attachments and the stored
note, message and vote rows go through insertRow, findRow and countRows.
The database mock loses its raw canAccessTrip and isOwner, which nothing
reads any more, and the repository parity cases keep the legacy joins they
are held to behind allow markers.
2026-10-08 18:46:51 +02:00
Maurice 3b1d30b587 test(server): seed the places integration and trip members repository specs through the factories
Converts the raw prepare() fixtures and reads in the places integration
suite and the trip members repository spec to the ORM factories and
readers. Tags, categories and linked expenses are seeded through makeTag,
makeCategory and makeBudgetItem; the member rows the roster cases backdate
or point at an inviter are written through insertRow and updateRows. The
legacy UNION and join statements the roster reads are held to stay raw
behind allow markers.
2026-10-08 18:46:51 +02:00
Maurice fecaa82ad2 test(server): seed the unified memories and admin service specs through the factories
Converts the raw prepare() fixtures and reads in the unified memories
service spec and the admin service spec to the ORM factories and readers.

The memories spec drops the raw canAccessTrip and isOwner on its database
mock, which nothing reads any more; its two parity cases keep the legacy
joins they are held to behind allow markers and build the provider list
they bind through the reader. The admin spec's password reset helpers
(password version, token and push device counts, the stored hash) now read
through the ORM, and the audit log parity case keeps its legacy statement
as an allowed oracle.
2026-10-08 18:46:51 +02:00
Maurice 345d459e5a test(server): seed the files and dawarich sync service specs through the factories
Converts the raw prepare() fixtures and reads in the files service spec and
the dawarich sync service spec to the ORM factories and readers.

The dawarich service stub the sync spec hands in now reads and writes its
connection rows through the ORM and is async, the shape the real service
has. Writes that set accepted_hash from the row's own source_hash read the
row first and write the value back, since a native update cannot reference
another column. The files spec drops the raw canAccessTrip and isOwner on
its database mock, which nothing reads any more, and keeps the legacy
FILE_SELECT statements it is held to behind allow markers.
2026-10-08 18:46:50 +02:00
Maurice 63bcf61a2b test(server): seed the system notice, packing e2e and synology specs through the factories
Converts the raw prepare() fixtures and reads in the system notices
integration suite, the packing e2e and the synology memories integration
suite to the ORM factories and readers.

The packing e2e moves from its hand-rolled tables to the migrated snapshot,
seeds its three users with the ids their session cookies are signed for,
and drops the raw canAccessTrip on its database mock, which nothing reads
any more. The synology suite now shares one ORM handle for the photo
registration helpers and the readers instead of declaring a second one
halfway down the file.
2026-10-08 18:46:50 +02:00
Maurice 2a9730d7c5 test(server): seed the demo repository, transports MCP and immich specs through the factories
Converts the raw prepare() fixtures and reads in the demo repository spec,
the transports MCP spec and the immich integration suite to the ORM
factories and readers.

Where a case compared a narrow SELECT with toEqual, it now picks the same
columns off the row the reader returns, so the comparison stays exact
rather than loosening to a partial match. The immich album link cases seed
their photos through addTripPhoto and addAlbumLink, which register the
provider asset the same way the raw INSERT OR IGNORE did.
2026-10-08 18:46:50 +02:00
Maurice e9ff28d84a test(server): seed the files MCP and roadtrip e2e specs through the factories
Converts the raw prepare() fixtures and reads in the files MCP spec and the
roadtrip e2e to the ORM factories and readers.

The files spec seeds its trip files through makeTripFile and its links
through linkFile, keeping the helper's explicit-null handling for size and
type. The roadtrip e2e keeps its pinned trip, day and place ids, which its
routes address directly, now written through insertRow and insertRows.
2026-10-08 18:46:50 +02:00
Maurice 635170a14f test(server): seed the share integration and passkey specs through the factories
Converts the raw prepare() fixtures and reads in the share integration
suite and the passkey service spec to the ORM factories and readers.

The passkey credential helper takes a typed set of overrides instead of a
loose record, so a fixture can no longer hand the insert a column the
entity does not map. The two credentials the listing case backdates are
written with their created_at through insertRows, blobs as Buffers.
2026-10-08 18:46:49 +02:00
Maurice 203eb6a216 test(server): seed the collections MCP and in-app notification specs through the factories
Converts the raw prepare() fixtures and reads in the collections MCP spec
and the in-app notification preference spec to the ORM factories and
readers. The collection, member and saved place helpers and the boolean
notification helper keep their signatures and simply resolve to the new
row's id, so each case reads as it did; the four copies of the boolean
notification INSERT collapse into one helper over makeNotification.
2026-10-08 18:46:49 +02:00
Maurice 9104dab183 test(server): seed the photo cache and dawarich specs through the factories
Converts the raw prepare() fixtures and reads in the trek photo cache and
google place photo cache services and the dawarich e2e to the ORM factories
and readers.

The place photo cache suite moves from its hand-rolled three-table fixture
to the migrated snapshot: the references it checks are now real places on a
trip and real saved places in a collection, seeded through makePlace and
makeCollectionPlace, which a table without names or owners could not hold.
The dawarich e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more.
2026-10-08 18:46:49 +02:00
Maurice 2860148df3 test(server): seed the plugin discovery, permission, feed and stay specs through the factories
Converts the raw prepare() fixtures and reads in the plugin discovery spec,
the permissions service spec, the feeds and invite MCP spec and the day
accommodations repository spec to the ORM factories and readers.

The permission cache cases still write their override rows past
savePermissions, now through setAppSetting, so the stale-cache behaviour
they pin is unchanged. The day accommodations spec keeps the legacy
statements it holds the repository to behind allow markers.
2026-10-08 18:46:48 +02:00
Maurice 855c4e3ba3 test(server): seed the collections, storage registry, transit, token and immich specs through the factories
Converts the raw prepare() fixtures and reads in the collections e2e, the
storage registry service spec, the transit MCP spec, the MCP token
repository spec and the immich memories integration suite to the ORM
factories and readers.

The collections e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more. Reads that used to join trip_photos to trek_photos
in SQL resolve the photo row through the readers instead, and the orphan
trip photo of IMMICH-057 is still written with foreign keys switched off,
now through the ORM on the same connection.
2026-10-08 18:46:48 +02:00
Maurice 8b2e7baacd test(server): seed the trip read model, share token and audit log specs through the factories
Converts the raw prepare() fixtures and reads in the trip read model
service spec and the share token and audit log repository specs to the ORM
factories and readers.

The trip read model spec drops the raw canAccessTrip and isOwner it still
put on the database mock; nothing has read them since trip access moved
onto the repositories. The share token spec now inserts through
makeShareToken and reads what a write left behind through findRow, keeping
the SELECT * row and the legacy statements it is held to as allowed
oracles. The audit log spec keeps the raw row and the typeof() storage
class probe behind allow markers, since both are about what SQLite stores.
2026-10-08 18:46:48 +02:00
Maurice a7fe990c6e test(server): seed the plugin settings, tag, rating, day and receipt specs through the factories
Converts the raw prepare() fixtures and reads in the plugin settings
isolation spec, the tags MCP spec, the place ratings and days repository
specs and the receipt scan e2e to the ORM factories and readers.

The receipt scan e2e writes the AI parsing addon config as the object the
entity's JSON column maps, not as a pre-serialised string, and seeds its
users and trips through makeUser and makeTrip with the ids its session
cookie is signed for. The days spec keeps the SELECT * row it compares the
ORM's rows with key for key, and its legacy statements, behind allow
markers; the place ratings spec backdates updated_at with dbNow instead of
datetime('now').
2026-10-08 18:46:48 +02:00
Maurice 2529255559 test(server): seed the websocket, push, api key and day specs through the factories
Converts the raw prepare() writes and reads in the websocket connection
suite, the web push and instance API key services, the days MCP spec and
the packing visibility and place details cache repository specs to the ORM
factories and readers.

The websocket suite reads the app's own ORM; the password version bump that
was a single relative UPDATE now reads the user and writes the next
version. The packing visibility parity keeps the legacy fragment it is
checked against behind an allow marker.
2026-10-08 18:46:48 +02:00
Maurice 7349ee95c2 test(server): seed the atlas repository and storage admin specs through the factories
The visited countries and place regions repository specs seed their rows
through the ORM; the legacy statements they hold the repositories to stay
raw behind an allow marker, since those are the oracles the parity is
checked against.

The storage admin e2e moves from hand-rolled tables to the migrated
snapshot, seeds its two users through the factories with the ids their
session cookies are signed for, and reads the stored backends and the audit
rows through the readers instead of raw SELECTs.
2026-10-08 18:46:47 +02:00
Maurice 9de9719a02 test(server): seed the share, quota, photo and stats specs through the factories
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.

The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
2026-10-08 18:46:47 +02:00
Maurice e3982a8e2a test(server): seed the repository, MCP and e2e specs through the factories
Converts the raw prepare() fixtures and reads in the next set of specs to
the ORM factories and readers: the password reset, webauthn, dawarich,
budget, to-do and vacay repository specs, the journey, settings, addons and
share MCP specs, the plugin POI and operator egress specs, the feeds,
storage stats and query helper services, and the packing, collections,
day reorder, leg mode, upload context and trip access integration suites.

The notifications and google quota e2e suites now seed their users through
makeUser with pinned ids, and the notifications suite moves from hand-rolled
tables to the migrated snapshot. Legacy statements that serve as a parity
oracle and the raw rows the primary-relation spike holds toObject() against
stay raw, each with an allow marker naming why.
2026-10-08 18:46:47 +02:00
Maurice 894a5ee781 test(server): seed the small leftover specs through the repositories
Converts the raw prepare() reads and writes in the share, airtrail, files,
todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard
specs to the ORM factories and readers, so they go through the entities the
app uses instead of hand-written SQLite.

The vacay e2e moves from hand-rolled tables to the migrated snapshot, with
its user pinned to the id its session cookie is signed for. Statements that
are raw by nature keep the allow marker with the reason: the legacy oracle
of the bucket list parity spec, the PRAGMA probe of the trek_photos
migration, the unmapped probe table of the route import rollback, the
behind-the-ORM reads of the entity insert probes, and seedUser, which also
serves the hand-rolled users table of createTempDb. Comments that quoted a
prepare() call are reworded so the ratchet no longer counts them.
2026-10-08 18:46:47 +02:00
Maurice ad1111858b test(oauth): read stored revoked_at raw in OAUTHTOKREPO-003
The case checks the text format revokeAllForClient and revokeById write.
Reading it through the entity runs DbTimestampType, which turns an epoch
integer or a Date into formatted text, so an integer write would still
pass. The two reads go back to raw statements under an allow marker and
assert the stored value is a string before matching its format.
2026-10-08 18:46:47 +02:00
Maurice 9561a2efc7 test(server): lower the raw statement baseline to what the seeded specs hold
The specs seeded through the repositories in the commits before this one
no longer carry unmarked raw statements, so their entries leave the
baseline. What stays raw in them is marked with a reason.
2026-10-08 18:46:47 +02:00
Maurice aec925cffb test(server): seed the journey domain spec through the factories
The journey domain suite now writes its photos, entries, contributors,
trip links, plan edits and place changes and reads back what a call left
through the ORM factories. Three small readers keep the shape a
statement's .get()/.all() handed back: one() answers undefined when no
row matches, pickOne()/pickAll() return just the named columns, and a
count stays { n }, so every toBeDefined, toBeUndefined and toEqual keeps
its meaning. The helpers that wrote or read rows (photoOnEntry, photoOn,
insertEntry, orderOf, daysOf, skeletonFor, skeletonsFor, skeletonsOf,
withGeometry) are async and awaited where they are used, and the M5d
parity seed binds its values in the old column order through columnsOf.

Kept raw and marked: the legacy statements of both repository parity
blocks (JG8, JG14, JG17, JG18, JG34 and the LEG set) and the unguarded
delete the JG119 mutation proof measures the guard against.
2026-10-08 18:46:39 +02:00
Maurice 2a51abaeb8 test(server): seed the budget service db spec through the factories
The DB-backed budget service suite now sets trip currencies, hangs
expenses on bookings, writes places, bookings, trip files, file links
and category order, and reads back items, members, payers, settlements
and links through the ORM factories. Its row readers (paidFlag,
personsOf, memberCount, itemRow, placeRow, settlementRow, totalOf,
rateOf, metadataOf, linksOf) and seeders (tripWithBooking, seedAudTrip)
are async now and awaited where they are read; projected comparisons map
the same keys off the found row.

The database mock drops its dead trip access stand-ins. Kept raw and
marked: the legacy BG10 to BG13 and settlement statements the repository
reads are held to, and the currency switch BUDGET-SVC-DB-066 makes from
inside the stubbed rate fetch.
2026-10-08 18:46:39 +02:00
Maurice 980b12aff2 test(server): seed the places repository spec through the factories
The places repository suite now edits its fixture places, writes tags,
ratings, tours, regions, stops and tracks, and reads back what a write
left through the ORM factories. The datetime('now', ...) offsets the old
statements computed in SQL are the same UTC second-resolution strings,
computed by sqliteNow, and the full-column fixture of PLACEREPO-050 goes
through insertRow with every column it named.

Kept raw and marked: the legacy getPlaceWithTags composition, the listForTrip
statement builder and every other legacy projection the typed reads are
compared against, plus the UPDATE in PLACEREPO-013 that must land behind
the ORM's back.
2026-10-08 18:46:38 +02:00
Maurice 847d98e8fd test(server): seed the reservations repository spec through the factories
The reservations repository suite now shapes its bookings, trips, stops,
stays, legs and day positions through the ORM factories, takes its day
ids from findRows and reads back the AirTrail and review columns a write
left with findRow.

Every legacy statement the typed reads are compared against stays raw
and marked as the oracle, the calendar, upcoming, share and public API
projections among them.
2026-10-08 18:46:38 +02:00
Maurice 55ccf3e399 test(server): seed the trips repository spec through the factories
The trips repository suite now edits its fixture trips and reads back
what a write left through the ORM factories (setTrip, tripRow,
findRow), and promotes its admin through updateRows.

The parity statements stay raw and marked: canAccessTrip, the owner
check, the viewer, active trip, feed token, travellers, sync and share
projections are the oracles the repository reads are held to. Also kept
raw: the SELECT * snapshot toObject has to reproduce, and the UPDATE in
TRIPREPO-022 that must land behind the ORM's back.
2026-10-08 18:46:38 +02:00
Maurice 9755c51c35 test(server): seed the days service spec through the factories
The days service suite writes bookings, legs, stays, vias, boundaries
and day edits through the ORM factories and reads them back with
findRow/findRows/countRows. The day, booking and stay readers the cases
compare against (dayRows, reservationRow, staySpan, stopsOn, viasOn,
boundaries) are async now and awaited where they are read; a reader of a
row that is gone fails the case as the old cast did.

The database mock drops its getPlaceWithTags, canAccessTrip and isOwner
stand-ins, which the module stopped exporting when those reads moved
onto the repositories.
2026-10-08 18:46:38 +02:00
Maurice b09f48c4d7 test(server): seed the packing service spec through the factories
The packing service suite writes its items, bags and templates and reads
back what a call left through the ORM factories. Template items are read
category by category instead of through a join, and the trip access
stand-ins the database mock still carried go, as the module no longer
exports them.

The repository parity block keeps its legacy statements raw, marked: the
VISIBLE_TO_ACTOR fragment, the assignee join, the correlated item count,
the category assignees and the export read are the oracles the converted
reads are held to.
2026-10-08 18:46:38 +02:00
Maurice 58cdaf828c test(server): seed the days integration, OIDC and share service specs through the factories
The days integration suite, the OIDC service suite and the share service
suite write their fixtures and read back their effects through the ORM
factories: invite tokens through makeInviteToken, user column edits
through updateRows, bookings, stays, packing items and messages through
insertRow on their entities. Two more database mocks drop their dead
canAccessTrip/isOwner stand-ins.

The OIDC identity-linking cases keep their raw SELECT * of the user row,
marked: they assert that no column of the table moved, including any the
entity might not map.
2026-10-08 18:46:38 +02:00
Maurice 5947631db6 test(server): seed the days, tours, assignments and journey share specs through the factories
The days and tours e2e suites, the assignments service suite and the
journey share suite build their fixtures through the ORM factories and
read back through findRow/findRows/countRows. Fixtures that pin ids (the
trip, day and places a session cookie or a route targets) keep them.
Readers that the old suites called synchronously (order, dayOrder,
viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where
a case compares several at once.

Left raw and marked: the legacy statements the parity cases hold the
repository reads to, the COLLATE NOCASE lookups that prove the token
match is exact, and total_changes(), which counts writes on the
connection itself.
2026-10-08 18:46:37 +02:00
Maurice a3781ed27b test(server): seed the collab, budget, MCP and OAuth token specs through the factories
The collab and budget e2e suites, the MCP integration suite, the budget
MCP tools and the OAuth token repository suite seed and read through the
ORM factories. The collab e2e suite moves off its hand-rolled CREATE
TABLE set onto the migrated snapshot, the way the factories guide asks of
a suite that converts: an entity reads and writes every mapped column,
which a trimmed table does not have. Its users are seeded with the ids
the session cookies are signed for.

The recursive chain statement in the token suite stays raw, marked as the
legacy oracle collectChainIds is held to.
2026-10-08 18:46:37 +02:00
Maurice 2cf853e504 test(server): seed the atlas and notes tools, day removal, doc sync config and plugin runtime specs through the factories
The atlas and notes MCP tool suites, day removal, the doc sync config
service and the plugin runtime conversion suite build their fixtures and
read back what a call left through the ORM factories. Helpers that used
to answer synchronously off the handle (dayRows, range, the reservation
and boundary readers) are async now and every caller awaits them; a
reader that answered undefined for a gone row still does, so the
toBeUndefined/toBeDefined checks keep their meaning.

The one statement left raw rewinds sqlite_sequence, the engine's own
autoincrement table, which no entity maps; it is marked.
2026-10-08 18:46:37 +02:00
Maurice 2b41a55022 test(server): seed the reminder, roster, Dawarich and plugin specs through the factories
Reminder jobs, user profile, trip members, Dawarich suggestions, the doc
sync controller, the plugin registry and the plugin repository parity
suite now write and read their rows through the ORM factories. The doc
sync controller's itemsForTrip stand-in answers through the two
repository reads DocSyncService makes instead of a copy of their SQL, and
one more database mock drops its dead canAccessTrip/isOwner stand-ins.

What stays raw is marked: the legacy statements the parity cases are
held to, and the places stub in the Dawarich suggestions suite, which
runs inside the service's open transaction and has to write on the
connection that transaction holds.
2026-10-08 18:46:26 +02:00
Maurice 49ba73fcae test(server): seed the atlas, settings, budget and notification specs through the factories
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
2026-10-08 18:46:26 +02:00
Maurice e2ce2ad05c test(server): seed the service, MCP and route specs through the ORM factories
Service, MCP, integration and e2e suites with up to a dozen raw
statements now seed and read through the factories. A buildApp() suite
uses the app's own ORM, a partial-module e2e or a unit suite a
createTestOrm() or sharedTestOrm() handle on its database. Reads that
used to come back undefined now come back null, and the assertions say
so; where a projected SELECT was compared whole, the same columns are
picked off the row.

Two reads inside the scheduler's delivery callback stay synchronous raw
statements, marked, so they keep seeing the row at the moment of
delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in
the module no longer exports.
2026-10-08 18:46:25 +02:00
Maurice d28e91d056 test(server): seed the repository specs through the ORM factories
The repository suites now write their fixtures and read back what a call
left behind through the factories and readers, not hand-written SQLite on
the better-sqlite3 handle. What stays raw is marked with test-sql-allow:
the legacy statements the parity cases hold a repository read to, rows
read as the table stores them (a BLOB, an integer the entity maps to a
boolean), and a write that has to land on the connection an open
transaction holds.

Cases that pinned a "raw UPDATE" between two repository reads now make
that write through a context of their own, which the repository's
identity map knows as little about, so they still prove the same thing.
2026-10-08 18:46:13 +02:00
Maurice b2f2aef016 test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
2026-10-08 18:46:13 +02:00
Maurice feda6ffdbc test(trips): seed the whole-copy parity reservation through insertRow
The TRIP-SVC-069 reservation seed was a raw INSERT carrying a
test-sql-allow marker, justified as reproducing the legacy raw binding of
a number into the TEXT accommodation_id. legacyBoundIntegerText already
writes that exact "<id>.0" shape and the sibling copy tests seed the same
column with it through insertRow, so nothing about this statement needs
to stay raw. The seed now goes through the factory with the same column
values, and the assertion still pins the legacy shape.
2026-10-08 18:46:06 +02:00
Maurice f5b29ddb50 test(server): lower the raw test SQL baseline to what the specs hold now
The seeding rounds above took the converted specs down to few or no
raw statements, so their entries in the ratchet drop to the new counts,
and the files that reached zero leave the baseline. A file that is
converted can no longer grow its raw SQL back unnoticed.

Two specs move to the exemptions instead, because raw SQL is their
subject: the migration step that reseats booked nights is tested on rows
seeded over the raw connection before the step runs, and the place
region trigger is pinned by raw updates on databases the migrations
alone built.
2026-10-08 18:46:06 +02:00
Maurice e4d0b40404 test(server): seed the trip and vacay service specs through the repositories
The two largest service suites seeded and inspected rows with raw
statements. They now write through the ORM factories and read through
small local readers that return a row the way the SELECT did (only the
selected columns, undefined when there is none), so every assertion keeps
its exact meaning. The trips database mock loses its dead canAccessTrip,
isOwner and getPlaceWithTags stubs.

Raw statements stay, marked, where they are the subject: the legacy day
grid rebuild that the fuzz case runs as its oracle inside a synchronous
transaction it rolls back, the reservation seed that reproduces the
legacy REAL binding into the TEXT stay link, and the legacy entry read
model the vacay suite compares against.
2026-10-08 18:46:06 +02:00
Maurice ba28b5538d test(server): keep the legacy TEXT shape of a booking's stay link
reservations.accommodation_id is TEXT, and every writer stores a stay id
in the shape the legacy statement bound it (`14.0`, see
legacyBoundIntegerText). The converted specs wrote and matched the bare
`14` instead, so a lookup of a booking the service had linked would miss
it and a seeded link would not look like one the app writes. They now go
through legacyBoundIntegerText, the same helper the services use.
2026-10-08 18:45:57 +02:00
Maurice 01f0bf3ef9 test(server): seed the larger service specs through the repositories
The settings, notification, road trip plan, collection and accommodation
service suites wrote their fixtures and read back their results with raw
statements against the shared test handle. They now seed and inspect
through the ORM factories and readers on the suite's shared test ORM,
with small local helpers where a suite repeats the same read.

The collection and accommodation database mocks drop their dead
canAccessTrip, isOwner and getPlaceWithTags stubs: db/database exports
none of them since the repositories took them over.

Raw statements stay, marked, where they are the subject: the legacy
read model oracle in the collections suite, the SELECT * that proves a
saved place has no itinerary columns, and the member count taken
synchronously while the delete broadcast fires.
2026-10-08 18:45:57 +02:00
Maurice 8eaeed07a8 test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports,
idempotency, invites, tokens, OAuth, push subscriptions, the version
check, budget settlement maths, addons, place shadow picks, tour GPX
import, the place region repair, journey stats, day notes and storage
jobs seeded and inspected their rows with raw statements. They now go
through the ORM factories and readers over the suite's shared test ORM.

The database mocks of day notes and OAuth also lose their dead
canAccessTrip and isOwner stubs: db/database exports neither since the
repositories took them over.

Raw statements stay, marked, where they are the subject: the backup
file the boot restore reads before any ORM exists, the full raw row a
day note is checked against, sqlite_master, the synchronous probe that
counts tours while the GPX import may still hold its transaction, and
the edits the region repair case lands mid-pass, past the ORM's
connection queue.
2026-10-08 18:45:56 +02:00
Maurice d8052c49b4 test(server): read the db, demo and helper specs through the ORM
The demo seed parity case, the transaction yield probe and the test
UnitOfWork check read their row counts and rows back with raw
statements; they now use the ORM readers.

The statements these files are about stay raw and are marked: the
dirty read and write the transaction yield probe measures on the shared
handle, and the reset-admin.js case, whose database file has no ORM
bound to it.
2026-10-08 18:45:56 +02:00
Maurice 514d5159ce test(server): seed the repository specs through the ORM factories
The repository suites for assignments, notifications, document sync,
tags, photo metadata, vacay, day notes, notice dismissals, idempotency
keys, settings, travellers, invites, providers, hidden regions, road
trip tracks and the collection role check seeded their fixtures and
read back what a write left behind with raw statements. Fixtures now go
through the ORM factories and insertRow/updateRows, and the state reads
through findRow/findRows/countRows.

What stays raw is raw on purpose and carries a test-sql-allow marker:
the legacy statements every parity case compares a repository against,
the full raw rows used as such an oracle, PRAGMA reads, typeof() and
date() storage checks, and the out-of-band writes the identity map
freshness cases are about, which have to bypass every EntityManager.
2026-10-08 18:45:32 +02:00
Maurice 57404dee17 test(server): seed the plugin specs through the repositories
The plugin service, instance and user settings, OAuth, audit, route
profile, host state and request context suites seeded plugin rows,
settings fields, actions, tokens and audit entries with raw statements
and read them back the same way. They now go through the ORM factories
and readers over the suite's own test ORM.

Two raw statements stay, marked: the PRAGMA table_info read that proves
the actions scope column exists, and the audit insert that reproduces
the pre-conversion appendAudit byte for byte for the hash chain parity
check. The instance settings suite's stubbed isActive now follows the
status the test flips through markActive instead of a synchronous read.
2026-10-08 18:45:32 +02:00
Maurice f73f395817 test(server): seed the integration specs through the repositories
The buildApp() suites for auth, bookings, budget, journeys, assignments,
passkeys, profiles, OAuth, the AirTrail tick and the plugin boot order,
plus the storage restore suite, seeded settings, addons and fixture rows
and read back what a request left behind through raw better-sqlite3
statements. They now write and read through the ORM factories and
readers, against the app's own MikroORM (or the suite's test ORM where
one already existed).

The account deletion case keeps every one of its foreign key checks; a
row that used to read back as undefined now reads back as null.
2026-10-08 18:45:32 +02:00
Maurice 583ba17606 test(server): seed the e2e specs through the repositories
The e2e suites for assignments, places, bookings, trips, the public API,
doc sync, addons, transit, to-dos, admin, invite links, school holidays,
categories, feeds and plugin search seeded and inspected their database
with raw better-sqlite3 statements. They now go through the ORM
factories and readers, the way the repositories write and read.

Six of them (public API, transit, to-dos, admin, invite links,
categories) still built a handful of hand-rolled tables. They move to
the migrated snapshot, since an entity reads and writes every mapped
column; where the real schema keeps usernames unique, the seeded users
get distinct names and keep the ones the assertions read back. The dead
canAccessTrip stubs left in two database mocks are dropped: nothing in
src imports it from db/database any more.
2026-10-08 18:45:20 +02:00
Maurice ddf97c5a21 test(server): seed the MCP tool specs through the repositories
The MCP tool suites seeded trips, places, bookings, packing, vacay and
settings rows and read back what a tool call left behind through raw
better-sqlite3 statements on the sync handle. They now go through the
ORM factories and readers in tests/helpers/factories, so the rows are
written and read the way the repositories do it.

Every assertion keeps its meaning: a missing row that read back as
undefined now reads back as null, and a partial SELECT is replaced by
the same fields picked off the full row.
2026-10-08 18:45:20 +02:00
Maurice 1bba65634c test(server): seed and read test data through ORM factories
The server suite seeds and reads its database through some 5,500 raw
better-sqlite3 statements on the sync handle. They restate the schema by
hand, skip the repositories the app goes through and run on SQLite only,
so nothing in the suite could ever exercise the dialect layer on another
engine.

tests/helpers/factories/ adds typed factories per domain (users, trips and
days, places, assignments, bookings, budget, packing, to-dos, files,
journeys, tours, collections, notifications, settings, plugins, tokens,
atlas, vacay, photos, collab) and generic readers (findRow, findRows,
countRows, updateRows, deleteRows) that go through MikroORM in a request
context of their own, against the app's ORM or a createTestOrm handle.
They refuse a write to a read-only foreign-key twin, which MikroORM would
otherwise drop without a word. The README there is the conversion guide.

The categories integration suite and the tags and calendar-feed e2e suites
are converted in full as worked examples; the tags suite also moves from
hand-rolled tables to the migrated snapshot.

lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left
to a per-file baseline: growth and stale entries fail, files whose subject
is raw SQL are exempt with a reason, and a single statement can be allowed
with a test-sql-allow comment. CI runs it next to lint:boundaries.
2026-10-08 18:44:39 +02:00
Maurice be751ad521 fix(auth): start no new session for a password change over a Bearer token
A password change ends every session of the user and re-issues one for
the device it was made on, handed out as the session cookie. A caller
that authenticated with a Bearer token never reads that cookie, so the
new session sat in the session list as a live device nobody held until
it expired.

The controller now asks for the replacement session only when the
request came with the session cookie, the same check the sliding
renewal makes. The response body is unchanged.
2026-10-08 18:44:08 +02:00
Maurice 648f63c626 fix(sessions): keep an ended session's row until it expires
A session renewed from a token issued before sessions were tracked has
an id derived from that token, and the renewal inserts it only when no
row by that id exists. The nightly purge deleted revoked rows straight
away, so the next request with the still valid old token inserted the
session again as an active one and got a fresh tracked token with a new
lifetime. A logout, an ended session or a sign out of the others held
only until the purge ran.

The purge now deletes expired rows only, and a revoked row stays until
its own expiry. A derived row is recorded with an expiry no earlier
than the old token's, so by the time it goes the old token is refused
by its signature check anyway.
2026-10-08 18:44:08 +02:00
Maurice e94525afd3 test(sessions): cover every path that ends a session end to end
The session e2e suite drove login, logout, the session routes, a
password change and renewal, but nothing showed end to end that a
password reset, an MFA disable, account deletion or an admin action
makes the old cookie stop working, the purge job had only a unit test
with a stubbed service, and reset-admin.js had no test of its session
handling at all.

sessions.e2e.test.ts now also resets a password through the mailed
token (both devices get 401, every row is ended, the new password signs
in), turns two-factor off from one device (the other gets 401, this one
stays), deletes the account (the rows go with it) and runs
SessionPurgeJob.tick against the real table (ended and expired rows go,
the live session keeps working). The admin paths need AdminModule and
with it most of the app, so session-revocation.e2e.test.ts boots
buildApp(): an admin setting a password, clearing two-factor and
deleting a member each sign both of the member's devices out and leave
the admin signed in, while an edit that leaves the password alone signs
nobody out. reset-admin-sessions.test.ts runs the script against a
database with session rows (only the reset account's live ones end) and
against one without the table.
2026-10-08 18:44:00 +02:00
Maurice 0dde01baa2 fix(oidc): sign in after SSO on a success flag, not the deprecated token
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.

The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
2026-10-08 18:44:00 +02:00
Maurice b872bb48a6 refactor(auth): check sessions through SessionsService, not its repository
The sessions domain owns user_sessions, yet AuthService injected
UserSessionsRepository next to SessionsService and AuthModule registered
the entity in its own forFeature, only so verifyJwtToken could hand the
repository to verifyJwtAndLoadUser.

verifyJwtAndLoadUser now takes a SessionLookup, the two repository
methods the session check uses (findActive, touchLastSeen).
SessionsService answers both, so AuthService passes itself through the
sessions domain's API and drops the repository and the forFeature entry.
The guards keep handing in the repository from their own entity
manager, which satisfies the same type. auth.service.ts lost lines, so
its size baseline goes down with it.
2026-10-08 18:43:50 +02:00
Maurice ac89895978 fix(sessions): renew a token from before tracking into one session
A token without a jti past half its life is renewed on every request
its cookie authenticates, and each renewal minted a session with a
random id. A first page load sends many requests at once, so one browser
ended up with several active sessions; only the last cookie survived,
and the rest showed up in GET /api/auth/sessions as separate devices
until they expired, up to 30 days for remember me.

The interceptor now hands the token to SessionsService.renew, which
derives the session id from it (a SHA-256 of the token, laid out as a
version 8 UUID so it passes the same id check) and inserts the row only
if it is not there yet. Every renewal of the same token lands on the
same session; if that session was ended in the meantime nothing is
renewed, just as for a tracked token. A caller that passes no token gets
the old behaviour.
2026-10-08 18:43:42 +02:00
Maurice 1ad6ae0ebc fix(auth): keep demo visitors out of each other's sessions
On a demo instance every visitor signs in as the one shared demo
account, so its sessions are other people's browsers. The session
routes had no demo check, unlike the password, MFA and account
changes, so any visitor could read the User-Agent of everyone else
signed in and sign them all out with revoke-others.

For the shared demo account in demo mode, GET /api/auth/sessions now
lists only the session the request came with, and DELETE
/api/auth/sessions/:id and POST /api/auth/sessions/revoke-others answer
403 "Sessions cannot be ended in demo mode." without ending anything.
Logout still ends the visitor's own session. Other accounts on a demo
instance keep the full routes. No MCP tool covers sessions, so there is
no parallel tool to change.

The demo sign-in now records the device's User-Agent on its session
like every other sign-in, which the session list needs to tell the
visitor's own entry apart.
2026-10-08 18:43:42 +02:00
Maurice 474beb0dfb fix(sessions): carry the signed-in sessions across a demo reset and a restore
Both swap the whole database file. The demo reset copies a baseline
saved at first seed, before anyone logged in, so it brings back an empty
user_sessions table; a backup restore brings the backup's own rows, or
none for a backup from before sessions were tracked. A session token
whose row is missing is refused, so the hourly reset signed every demo
visitor and the demo admin out, and the admin who ran a restore got a
401 on the next request. Before session tracking only the password
version applied, and those tokens survived.

Both paths now read the active rows with their owner's email before the
swap and put them back after the reopen, in a fresh fork, for every
account the new file still holds under the same id and email. A row the
new file already has is left as it is, so a session that ended there
stays ended, and a session whose id now names somebody else (a backup
from another install) is dropped instead of handed to them. The password
version check still refuses a token the restored account no longer
matches. Reading or writing them is best effort and never fails the
reset or the restore.
2026-10-08 18:43:42 +02:00
Maurice 9aab9e7513 feat(auth): track sign-in sessions so a session can end before it expires
A session token was a signed JWT and nothing else. Logout only cleared
the cookie, a 30-day "remember me" token stayed valid wherever a copy of
it had ended up, and nobody could sign out one device short of changing
the password or rotating the JWT secret for everyone.

Every session token now carries an id (jti) naming a row in the new
user_sessions table (id, user_id, created_at, last_seen_at, expires_at,
revoked_at, user_agent cut to 256 characters; no address is kept). The
rows live behind UserSessionsRepository and the new sessions domain,
whose SessionsService is the one issuer: AuthService.generateToken and
the OIDC sign-in both go through it, and sliding renewal re-signs a
tracked token under the same id and moves its expiry. verifyJwtAndLoadUser
takes the repository and refuses a token whose session is revoked,
expired or gone, refreshing last_seen_at at most every five minutes.
A token without a jti was issued before this change; it passes until its
own expiry (or a password change), so the upgrade signs nobody out, and
renewal turns it into a tracked session.

Sessions end on logout (the one the request names, verified even when
expired), on a password change (all, the device gets a new one), a
password reset, an admin setting a password, reset-admin.js, an MFA
disable (all but the current one), an admin clearing a user's MFA, and
with the account. GET /api/auth/sessions lists the active sessions,
DELETE /api/auth/sessions/:id ends one and POST
/api/auth/sessions/revoke-others ends all but the current, with their
contracts in @trek/shared and two new audit actions. There is no UI for
them yet. MCP exposes no account or session tools, so there is no
parallel tool to change; an MCP call made with a session JWT as bearer
goes through the same check.

The token in the login, register, demo-login, MFA and passkey bodies
stays for API clients but is marked deprecated in the API docs and in
the client type, which never read it.

A nightly session-purge job on CronRegistrarService deletes the expired
and revoked rows. The migration is 042500. The sessions domain gets its
own coverage entry at the 80 floor until a full run measures it.
2026-10-08 18:43:06 +02:00
Maurice 094e0b28f8 fix(booking-import): write an imported venue with its booking in one transaction
confirm() created the venue's place row in its own transaction and
broadcast place:created before the booking and its cost were written in
a second one. When that second write failed, the item was skipped but
the trip kept a place no booking pointed at, and clients had already
been told about it.

Per item, the venue and endpoint geocoding, the day lookups and the
cost's frozen rate still run first, outside any transaction. The place
row, the booking and its linked cost then go into one transaction
through the new ReservationsService.createWithCostInTx, the core that
createWithCost itself now wraps. place:created, reservation:created,
accommodation:created, the stay mirror and the cost events are sent
only after the commit. Each item keeps its own transaction, so a
failing item still leaves the others imported; confirm() is tagged
@txIndependent for that and leaves the lint:tx baseline.
2026-10-08 18:42:10 +02:00
Maurice 9cebadf67c fix(memories): save the Immich connection and auto_upload in one transaction
PUT /api/integrations/memories/immich/settings saved the connection through
one service call and then wrote auto_upload through a second one from
the controller, each committed on its own. A failure between them left
a new connection with the old upload choice.

saveImmichSettings takes the auto_upload flag and writes it with the
connection (or the disconnect) inside one transaction, after the SSRF
check that does the DNS lookup. An absent or non-boolean flag still
keeps the stored choice, a refused URL still writes nothing, and the
response is unchanged. The controller's write sequence leaves the
lint:tx baseline, and the MemoriesService facade loses the
immichSetAutoUpload pass-through it no longer needs. No MCP tool writes
these settings.
2026-10-08 18:42:10 +02:00
Maurice f40d92a69e fix(trips): write the budget currency rebase with the trip row
TripsService.update rebased the budget onto the new currency in its own
transaction and only then wrote the trip row in a second one. When the
day grid rebuild, the booking resync or the leave shift threw after the
rebase had committed, budget items and settlements stayed pinned and
re-frozen against a currency the trip never took (#1543).

rebaseTripCurrency is split into prepareCurrencyRebase, which fetches
the rates before any transaction opens, and applyCurrencyRebase, which
reads the outgoing currency and writes the rebase. update() fetches
first and hands the plan to updateTrip, which applies it inside the
transaction that writes the row, the leave shift and the day grid.
updateTrip called without a plan (the plugin RPC host) still never
rebases, and rebaseTripCurrency keeps working as before.

The updateTrip docstring no longer claims the row is written outside
the day regeneration transaction. TripsService.update leaves the lint:tx
baseline, and the size baselines shrink with the files.
2026-10-08 18:42:10 +02:00
Maurice 615a0b14f5 fix(reservations): roll a booking write back when its linked cost fails
createWithCost and updateWithCost run the booking and its cost in one
transaction, but the cost sync still caught and logged its own errors
inside it. A price that could not be written therefore committed the
booking edit and left the linked expense on its old figure; only the
category re-file path actually rolled back.

The sync no longer swallows a failing cost write. Both WithCost methods
are its only callers, so the error now reaches their transaction and
the booking and its cost commit or fail together. A booking import
item whose cost cannot be written is skipped like any other failing
item instead of landing without its cost.
2026-10-08 18:42:10 +02:00
Maurice 2fb699189e test(journey): add gallery and entry photos concurrently
uploadGalleryPhotos and addPhoto read MAX(sort_order) inside the transaction
that inserts, but the only gallery test ran its uploads one after another and
would pass without that. These cases run several uploads, and several photos
added to one entry, at once and expect consecutive positions in the gallery
and on the entry, as JOURNEY-SVC-030b does for entries.
2026-10-08 18:42:09 +02:00
Maurice c7feaf0a65 fix(reservations): link a cost created on a booking update on its insert
syncBudgetOnUpdate created the expense and then set its reservation_id in a
second statement, inside a try that logs and swallows the failure. Running
inside updateWithCost, a failed back-link committed an expense that was not
linked to its booking. The expense is now created through
linkBudgetItemToReservation, which writes the link on the insert, the same
way syncBudgetOnCreate does.
2026-10-08 18:42:09 +02:00
Maurice b84867b4c2 fix(mcp): update bookings through updateWithCost like REST and the plugin RPC
update_reservation and update_transport accept a type but still called the
bare ReservationsService.update. Only updateWithCost re-files a linked expense
that still carries the category derived from the old type, so changing a
booking from flight to train over MCP left its expense filed under flights
while the same change over REST or RPC moved it. Both tools now call
updateWithCost without a price entry, and send the cost events and the
accommodation change after the commit, as REST does.
2026-10-08 18:42:09 +02:00
Maurice 36a0d1eaba fix(auth): erase plugin user data in the transaction that deletes the user
erasePluginUserData deleted the host-side plugin rows and then queued the
plugin erasures as separate commits, and deleteGuest ran it before its own
transaction. When the guest delete failed, the guest kept its account but had
lost its plugin rows, with erasures queued for a user that still exists. The
erasure is now one transaction, and deleteGuest runs it inside the same
transaction as the budget re-split and the user delete, so it becomes a
savepoint of that write. Only DB writes and a synchronous readdir run inside.
2026-10-08 18:42:09 +02:00
Maurice b7e13e4af6 fix(booking-import): write an imported booking with its cost in one transaction
Confirming an import created the booking, then froze the FX rate and created
the linked cost in a second commit, so a failed cost write left the booking
without its expense. REST, MCP and the plugin RPC already go through
ReservationsService.createWithCost. The import now builds the cost entry and
freezes its rate first, outside the write since that can fetch rates, then
calls createWithCost and announces the cost after the commit. A rate that
cannot be frozen keeps the booking without its cost, as before. The place a
venue becomes still commits on its own, with endpoint geocoding between it
and the booking, so confirm stays in the lint:tx baseline at 4.
2026-10-08 18:41:57 +02:00
Maurice 60a42cfac1 fix(airtrail): import a flight with its AirTrail link in one transaction
The import created the booking and wrote its external_* link as two commits.
A failed link left the booking in the trip while the import reported the
flight as skipped. The flights are fetched before the loop, so nothing between
the two writes waits on the network: each flight or joined connection now goes
in with its link in one transaction, and the method is marked as one
transaction per imported booking instead of being held in the lint:tx baseline.
2026-10-08 18:41:57 +02:00
Maurice 8018f10273 feat(server): gate writes outside a transaction with lint:tx
The rule that a multi-statement write runs in uow.transactional was
written down but nothing held it. scripts/tx-writes.mjs reads every class
under src/ with the TypeScript parser and counts, per method, the writes
it issues outside a transactional callback: repository calls that build
an INSERT, UPDATE or DELETE, calls to methods that write (on the class
itself or on a constructor-injected one), and a transaction holding
writes as one. A write in a loop counts twice, of two branches only the
larger counts, local helpers count where they are called, and a helper
whose every call sits inside a transaction is not reported. --explain
lists the line of every call that counted.

Today's 102 reported methods sit in scripts/tx-writes-baseline.json and
may only shrink; most are syncs with network I/O between their writes,
plugin lifecycle steps and adapters calling two service writes. A new
method fails CI, and an entry above what its method holds fails until
--update lowers it, the same ratchet as lint:size and lint:boundaries.

Two JSDoc tags mark the deliberate cases with their reason in the tag:
@txStandalone for a write that stands on its own whatever the caller does
(the audit row, notification sends, the booking price mirror, the
assignment journey refresh, the quota retention purge), @txIndependent
for writes complete one by one on purpose (the OAuth token retention
sweep, the notification claim and its release). Runs in CI after
lint:boundaries; documented in server/CLAUDE.md, the root CLAUDE.md and
the wiki's dev setup page.
2026-10-08 18:41:57 +02:00
Maurice 9dd2e1b983 fix(server): take MAX+1 positions inside the transaction that inserts
Days, todo items, packing items and bags, packing template categories and
items, collections, collection labels and road-trip vias took their next
position as MAX(...) + 1 in one statement and inserted in the next, with
any other request free to run in between: two adds at once read the same
MAX and landed on the same position (the road-trip test pinned exactly
that duplicate). The read now sits in the transaction that inserts, and
the transaction holds the connection, so the second add waits and takes
the next position. A collection label's limit and name check move into
the same transaction, so two labels of one name added at once end in one
label and a 409.

Each site gets a test that adds several rows at once and reads back one
position each.
2026-10-08 18:41:32 +02:00
Maurice e015e5e5e8 fix(server): run the remaining multi-statement service writes in one transaction
A sweep of the services for write sequences that committed step by step,
each one leaving half a change behind when the next statement failed:

- accommodations: the linked bookings' times moved after the stay's own
  transaction had committed
- airports: the boot backfill wrote a flight's two endpoints one at a
  time, and a flight left with only the first was never picked up again;
  each flight is its own transaction now
- auth: the admin settings form and a password reset (retire the old
  link, issue the new one) each land whole
- oauth: a refresh revoked the old pair before issuing the new one, so a
  failure in between logged the client out; rotating a client secret and
  revoking its tokens go together too
- oidc: the identity link, the role from the claims and the avatar of a
  returning user are one write
- addons, notification preferences: a settings save lands whole
- places: create with its tags, update, and the GPX/KML imports with
  their track colours
- trips: the trip row, the shifted leave entries and the rebuilt day grid
- vacay: a first visit provisions the plan in one transaction with a
  second look against a concurrent first visit; updatePlan fetches the
  public holidays between two transactions instead of inside its writes
- memories: a photo and its trip link, and the deletes with the orphan
  sweep behind them

AddonsService, AirportsService and OauthService take the UnitOfWork for
this. Network I/O stays outside every transaction. Comments that only
narrated the migration plan make room under the lint:size entries, which
drop with them.
2026-10-08 18:41:32 +02:00
Maurice f76538b2b2 fix(plugins): uninstall a plugin and record its install in one transaction
Uninstall deleted the plugin row and then up to fourteen tables of rows
about it one by one. A failure halfway left grants, scheduled tasks or
per-user settings behind for a later plugin that reuses the id to
inherit. The rows now go in one transaction, and the data directory is
removed after the commit instead of before the rows that describe it.

A registry install records the discovered row, its provenance, the
pinned author key and the cleared update block together, so a failure
never leaves a registered plugin without its pinned key. Starting an
OAuth connect drops the stale state and stores the fresh one together,
and a disconnect drops tokens and state together; PluginOAuthService
takes the UnitOfWork for that. The scheduler's per-task claims stay
separate on purpose and say so for the transaction gate.
2026-10-08 18:41:06 +02:00
Maurice f6076328fe fix(journey): create a journey with its owner, trips and photos in one transaction
createJourney inserted the journey, then its owner row, then linked each
trip with its skeleton entries and set the cover, every step committed on
its own: a failure halfway left a journey nobody owned. It now runs as
one transaction and broadcasts the linked trips after the commit.
addTripToJourney links inside a transaction the same way.

The photo paths wrapped only the gallery insert and left the entry link
and the skeleton promotion outside; the photo row, the gallery row, the
link and the promotion are one write now. removeTripFromJourney,
updateEntry, updatePhoto, setPhotoProvider, the photo deletes and the
place hooks write in one transaction each.

createEntry and uploadGalleryPhotos read MAX(sort_order) inside the
transaction that inserts, so two entries or uploads at once take one
position each. reconcileTripSkeletons and the photo capture helpers are
tagged as deliberately split for the transaction gate that follows.
2026-10-08 18:41:06 +02:00
Maurice 5cbd9e9d36 fix(reservations): write a booking and its linked cost in one transaction
A booking's create and update ran their own transaction, and the linked
cost sync ran afterwards from the REST controller and the plugin RPC
handler, so a failing cost write left the booking edited and its expense
on the old category or price. MCP linked the cost in a second call of its
own the same way.

ReservationsService.createWithCost and updateWithCost now run the
booking write and the cost sync in one transaction and hand the cost
events back, so REST, MCP and the plugin RPC call the same method and
broadcast only after the commit, in the order they did before. The cost
sync keeps swallowing its own failures the way the REST route always
has, so a cost that cannot be written still leaves the booking; MCP now
behaves like REST there instead of failing the tool after the booking
was already saved.

The service sheds its inventory markers (RS1..RS54, plan history) to
stay under its lint:size entry.
2026-10-08 18:41:06 +02:00
Maurice 465480630b fix(plugins): report a plugin SQL call done once its last statement has run
The time budget for an exec script was read after every statement, the
last one included. A single statement without args, or a script whose
closing COMMIT crossed the budget, had already run and committed, yet the
call threw a time budget error. A plugin that retries on error then wrote
the same rows a second time.

The script now reads the clock before each statement after the first, so
it still stops before the next statement once the time is up and rolls
back a transaction it opened, but a script whose statements have all run
is reported as done. query() likewise reads the clock only once another
row has come back, so a result read to its end is returned.

The ctx.db doc in the SDK said a single statement was not timed, which
contradicted the query budget. It now names what is really untimed: an
exec with bound args and migrate. README and wiki describe the script
stopping before its next statement.
2026-10-08 18:41:02 +02:00
Maurice d3be6fb2b8 fix(plugins): hold exec scripts to the time budget between statements
The time budget covered query() and tx() only. exec() without bound args
handed the plugin's script to Database#exec, which runs any number of
statements in one call with nothing checked between them, so a long
script held the host loop as long as it liked.

An exec script now runs one statement at a time. sql-script.ts cuts it
after every semicolon outside a string, a quoted identifier or a
comment; a piece SQLite reports as incomplete input (a trigger body cut
at its inner semicolon) is joined with the next until it prepares. The
clock is read after every statement, and past the budget the call
throws. As with Database#exec, the statements before that point stay
applied, and an error is SQLite's own; a transaction the script opened
itself is rolled back so the connection is not left inside it.

exec() with bound args is one statement and stays as it was. A single
statement cannot be bounded at all on the host thread, a write such as
INSERT ... SELECT over a cross join included, and the SDK README and the
wiki now say so next to the cross-join read. migrate() keeps running
unbudgeted on purpose: a migration stopped part of the way is rolled back
and fails the same way on every later start, leaving the plugin unable to
update its schema.

The ctx.db rows of the SDK README and the plugin wiki pages describe the
budget for scripts, and the lines touched there and in plugin-sdk's db
comment lose their em dashes.
2026-10-08 18:41:02 +02:00
Maurice 9dec36995e feat(plugins): extend the output allowlist to child rows and whole-row read models
The output contract cut only the top level of an entity result. A derived
key's value went out untouched, so the children that are whole rows still
carried every column: a day's notes_items are full day_notes entities and
a reservation's endpoints are SELECT e.* on reservation_endpoints. About
fifty methods were also left as readModel and passed through whole, many
of them backed by SELECT * reads (day_accommodations, day_notes, todos,
tags, packing bags, journal entries, collab, collections, vacay plans).
A new column on any of those tables reached every plugin by default,
which is what the contract was meant to stop and what the SDK README and
the wiki already claimed it did.

PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold
rows of another entity, and the shaper cuts those rows down to their own
field list, recursively. dayNote and reservationEndpoint are the child
tables that were whole rows; day.assignments and place.tags are held to
the assignment and tag lists too, although their reads already name
their columns. The remaining derived children (travelers, participants,
ratings, budget members, payers and receipts, packing recipients,
attachments, voters, bag members) are built field by field, which the
contract comment now states as the rule for a derived key without a
nested entity.

The whole-row read models get entity contracts: accommodation, dayNote,
todo, tag, category, packingBag, fileLink, collabNote, collabPoll,
collabMessage, journey, journalEntry, bucketItem and collection, and
vacayPlan with its holiday calendars. collections.listMine,
collections.get and vacay.mine return envelopes around rows; an entity
with a null table describes those, so their rows are reached through the
same nesting. Every list holds exactly the keys each read returned
before, so no plugin loses a field. What stays readModel is built field
by field from named columns, and each such entry says what it returns:
setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the
two vacay toggles and journal.addEntryPhoto.

The schema test now covers every child and new table, and calls each
newly shaped method on the real services, comparing nested rows key for
key so the only field allowed to disappear is a withheld one. A further
case proves the nested rows it compares are not empty. gen:plugin-facts
publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin
wiki pages and server/CLAUDE.md describe the nested rows and list the new
entities.
2026-10-08 18:41:02 +02:00
Maurice 0c9e1cd639 fix(plugins): hold plugin SQL to a time budget between rows
A plugin's own database runs on the host's event loop, and the only bounds
were a regex denylist, the 256 MB quota and a 100,000 row cap. A select
whose every row is expensive, or a long scan under the cap, could hold the
loop for as long as it liked.

query() and tx() now get a wall-clock budget of 2 s, read between the rows
a statement yields and between the statements of a batch. Past it the call
throws, and a tx rolls back whole like any other failure inside it. The
row cap and the budget are overridable per PluginDataDb for tests; the
defaults are what production runs.

better-sqlite3 has no interrupt or progress handler, so a statement that
yields nothing until it is done, such as an aggregate over a cross join,
still runs to its first row. Only moving plugin SQL off the host thread can
bound that; the SDK README and the plugin wiki state the budget and this
limit.
2026-10-08 18:41:01 +02:00
Maurice 95f9f031c4 feat(plugins): cut entity results down to a published field list
The entity reads of the plugin RPC surface handed plugins whatever their
query produced, mostly SELECT t.*, with credentials stripped by a denylist
(withoutFeedToken). Every column a migration added reached every plugin
holding the read grant, and renaming one was an unversioned break of a
published API.

protocol/output-contract.ts now states, for every wire method, what its
result is: rows of one of the nine entities the SDK types (trip, place,
day, reservation, packingItem, tripFile, budgetItem, assignment, user), a
value the host builds itself, or a read model passed on as the REST route
returns it. The router applies it to each result on the way out, so an
entity row keeps exactly the fields plugins received when the list was
drawn, in the same order and with the same values, minus the trip's
feed_token (listMine, create and update used to carry it as null). The
table is keyed by every method, so a new one does not compile without an
entry.

output-contract.schema.test.ts holds the lists to the migrated schema: a
new column on a whole-row table fails until it is published or withheld,
and each entity method is called on the real services to prove the
contract drops nothing but the withheld fields.

gen:plugin-facts publishes the lists to the SDK as PLUGIN_ENTITY_FIELDS
and PLUGIN_METHOD_RESULT, and the SDK's entity interfaces are type-checked
against them. Place.day_id, which no read ever delivered, is deprecated
rather than removed. KNOWN_ADDONS is generated from ADDON_IDS as well,
which adds the dawarich, roadtrip and tours ids the hand-kept copy lacked.
The SDK goes to 1.9.0, and its README and the plugin wiki pages describe
what a ctx call returns.
2026-10-08 18:41:01 +02:00
Maurice 8c6157aa46 test(server): pin the request id through the body parsers
A handler behind express.json() is reached from the request stream's 'end'
event, long after the correlation middleware returned, so it looked as if
every write lost its id. It does not: raw-body and multer bind their
callbacks with AsyncResource at the point they are called, which is inside
the request's correlation. Nothing tested that, though, because every
REQID case was a GET. REQID-007 posts a JSON body and REQID-008 one that
arrives in several chunks, and both expect the handler to see the id the
response carries. The middleware comment now says why this holds.
2026-10-08 18:41:01 +02:00
Maurice aa8d9b4037 refactor(server): inject the transit base URL and the kitinerary paths as config tokens
The ownership rule says a boot-stable value that only Nest providers read
belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a
module-top readEnv() const and KitineraryExtractorService read
KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now
come from tokens: transitConfig and kitineraryConfig, derived in
boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer
reads those variables at all. TransitModule and KitineraryExtractorModule
import AppConfigModule, which keeps them buildable on their own in tests.

Behaviour is unchanged: the same defaults, the same trailing-slash strip and
PATH split, now frozen per built app instead of at import. The app-config
README names each boot-stable variable that stays on readEnv() together with
the reader the container cannot inject into.
2026-10-08 18:41:01 +02:00
Maurice a6f635f3e0 docs(server): keep the managed-hosting switches out of the env reference
The env reference parity test forced TREK_MANAGED, MAPBOX_ACCESS_TOKEN,
CARTO_API_KEY and PLACES_API_BASE into server/.env.example with how-to text.
They are managed hosting only and stay out of the public docs, the same way
TREK_DB_FILE and APP_VERSION do, so they move to NOT_OPERATOR_SETTINGS with
that reason and their lines leave the self-hoster reference again. The
variables themselves and their behaviour are unchanged.
2026-10-08 18:41:01 +02:00
Maurice 558203213c fix(server): keep idle connections open longer than Traefik's upstream timeout
The 65 s default sat above nginx's 60 s but below the 90 s Traefik keeps an
idle upstream connection, so a default Traefik deployment could still reuse a
socket Node had just closed and answer that request with a 502. 95 s clears
both, and the headers timeout one second above it stays well under Node's
300 s request timeout and the 290000 cap the schema accepts.

Also moves the stray RawEnv import in boot-derive.ts up to the other import.
2026-10-08 18:41:01 +02:00
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00
Maurice fa0e6a3eae refactor(server): take the storage roots from DataPathsService
The data-directory change said Nest classes inject DataPathsService, but
only AdminService did. StorageRegistryService still read the built-in
uploads and backups roots and the scratch dir from module constants
frozen at import, and StorageJobsService read the scratch dir the same
way.

The registry now takes DataPathsService through its constructor (a
hand-built one resolves the same layout), and the jobs service asks the
registry for its temp dir. The plain helper modules keep calling
resolveDataPaths() because they run without a container or are shared
with code that does: config.ts, the database path, index.ts, the logger,
the backup functions and the plugin trees, which the out-of-process
supervisor reads too. The app-config README now lists both sides, and
the nest README, server/CLAUDE.md and the provider's own comment say the
same.
2026-10-08 18:40:52 +02:00
Maurice d1b461a37f docs(server): name HTTP_KEEP_ALIVE_TIMEOUT_MS as an httpConfig variable
The keep-alive change moved HTTP_KEEP_ALIVE_TIMEOUT_MS onto the httpConfig
token, but the app-config README still listed only TRUST_PROXY and
HSTS_INCLUDE_SUBDOMAINS for it and left the variable out of the
boot-stable list. Also drops the dashes from two lines the recent
scheduling and entity changes rewrote.
2026-10-08 18:40:37 +02:00
Maurice a72497b3c4 fix(server): trace a cron tick lost to another process as skipped
A tick whose lease another process held returned from runLeased without
running, and traceEntry still wrote `cron <name> ok`, so both processes
logged the tick as run and the per-tick line could not tell which
replica did the work. runLeased now says whether the tick ran, and
traceEntry takes a `skipped` option that logs such a call at debug as
`cron <name> skipped <ms>: lease held by another process`.

Taking the lease is two statements (make sure the row exists, then the
conditional UPDATE that decides the race), so it now runs inside
UnitOfWork.transactional like every other multi-statement write.
2026-10-08 18:40:37 +02:00
Maurice 06dfdc6943 refactor(server): put process-local state behind injectable stores
Rate-limit counters, OAuth pending codes, OIDC login states and codes,
the permissions cache and the WebSocket rooms were Maps on module or
instance scope. That is right while TREK runs as one process, but nothing
could be swapped without editing every consumer.

Each now sits behind a port with an in-memory implementation that keeps
the old behaviour exactly: RateLimitStore, PendingCodeStore,
OidcFlowStore, PermissionsCacheStore and RoomRegistry. The owning module
provides the implementation with the same lifetime the state had
(process-wide for the pending codes, the permissions cache and the rooms,
per instance for the rate limits and the OIDC flows), the consumers take
it through their constructor, and a hand-built consumer falls back to the
same in-memory store, so tests and the free-function paths (the backup
restore's cache flush, the broadcasts) see what they saw before. A store
shared between processes can replace a provider later without touching
the services.
2026-10-08 18:40:37 +02:00
Maurice 5cb87a4df5 feat(server): add last-resort error handlers, a draining readiness probe and proxy keep-alive
Three small gaps in how the process behaves at its edges:

- An unhandled rejection or uncaught exception crashed the process with
  the stack on stderr only. index.ts now logs it to trek.log and runs the
  same orderly shutdown a SIGTERM gets, exiting with 1; a second fatal
  error during that shutdown exits at once.
- /api/health/ready kept answering 200 while a SIGTERM was draining the
  process, so an orchestrator sent traffic for another probe period. A
  shutdown now marks the new ReadinessService first (and Nest's own
  shutdown hook marks it too), and the probe answers 503 from then on.
- Node closes an idle keep-alive connection after 5 seconds while reverse
  proxies keep upstream connections for about a minute, which shows up as
  intermittent 502s. The server now keeps them for 65 seconds, configurable
  through HTTP_KEEP_ALIVE_TIMEOUT_MS (owned by the httpConfig token), with
  the headers timeout one second above.
2026-10-08 18:40:37 +02:00
Maurice c5bc6798a9 feat(server): take a database lease before a cron tick runs
waitForCompletion only kept a tick from overlapping itself inside one
process. Two processes on the same database (a rolling update's overlap,
a second replica, a dev container pointed at the same file) both ran
every job: two auto-backups, double syncs and retention sweeps, and
reminder runs racing each other.

A new migration adds scheduler_leases, and CronRegistrarService now runs
every registered tick through SchedulerLeasesRepository.acquire: one
conditional UPDATE moves the job's row to this process when it is free
(no holder, a lapsed holder, or this process already), so exactly one
process wins. A running tick renews its lease every 20 seconds, a crashed
holder frees the job after a minute, and a finished tick keeps the lease
for 30 seconds so a peer whose timer fires late does not run the same
tick again. The per-row reminder claims stay as they are; jobs need no
change of their own.
2026-10-08 18:40:15 +02:00
Maurice 8632f93c37 feat(server): correlate log lines with a request id per entry point
A 5xx stack and its access-log line were two unrelated lines, the
exception filter printed the stack with console.error and no request
context, and the MCP, WebSocket, plugin RPC and cron entry points logged
nothing per call. A self-hosted bug report could not be traced from a
failure back to the call that caused it.

Every HTTP request now runs under an id held in AsyncLocalStorage: a
well-formed incoming X-Request-Id is kept, anything else is replaced by a
UUID, and the response carries it in X-Request-Id. The logger tags every
line written inside a unit of work with `[http <id>]`. The exception
filter leaves a server-side failure on res.locals and the access log
writes the request line and the stack as one entry under that id; without
an access log watching the response it still prints the error itself.

MCP tool, resource and prompt calls (through a new `around` seam on the
registry), WebSocket messages, plugin RPC dispatches and cron ticks each
run through traceEntry(), which gives the call its own id (remembering the
outer one as its parent) and logs one line for it: debug on success, like
the HTTP access log, and warn or error with the reason on failure. The
call's result and timing are unchanged, a failed cron tick is logged once
rather than twice, and a WebSocket handler rejection no longer reaches
rxjs's asynchronous rethrow.
2026-10-08 18:40:15 +02:00
Maurice a82cb3adbb perf(server): write the file log in batches off the event loop
Every log line ran existsSync, statSync and appendFileSync on the event
loop before it returned. On a slow data volume (an SMB or NFS bind mount
on a NAS) that stalled every request that logged, and rotation renamed
files without checking whether another process sharing data/logs had
rotated already.

The file half of the logger is now BufferedLogFile: a line is queued in
memory and the queue goes out with one appendFile per batch, through a
single serial chain, so no append runs while files are being renamed. The
files and the rotation are the same as before (trek.log rotates to .1 at
10 MB, five files in total), but the size is re-read before a rotation
and a file another process already rotated is left alone. A stalled disk
costs dropped lines with a count instead of unbounded memory, a failed
write is reported instead of thrown, and index.ts writes whatever is
still queued synchronously when the process exits.
2026-10-08 18:40:15 +02:00
Maurice ba1504d6e9 refactor(server): give every environment variable one config owner
Eight registerAs tokens existed while every consumer read the same values
through readEnv() instead, so the boot-stable layer the repo rules describe
was unused and nothing said which path owned a variable. FORCE_HTTPS, for
instance, was frozen by the middleware through the http token and read live
by the cookie code at the same time.

A variable is now owned by exactly one side. TRUST_PROXY and
HSTS_INCLUDE_SUBDOMAINS belong to httpConfig, which buildApp() already
injects into the pre-init middleware, and TREK_PLACE_PHOTO_DIR to the new
storageConfig, which the storage registry now injects instead of reading it
through RuntimeEnvService. Their derive functions moved to boot-derive.ts,
which deriveAll() never calls. The six tokens nothing injected are gone:
their values are needed before the container exists (config.ts, the
database, src/mcp, the SSRF guard) and stay on readEnv().

config-ownership.test.ts records the keys each side reads through a Proxy
and fails on a variable read through both, on a token nothing injects and
on a variable the schema does not validate. env-reference.test.ts holds
server/.env.example to the schema, which had drifted by 34 variables (SMTP,
WebAuthn, plugins, MCP session tuning, TREK_MANAGED and others); they are
documented now. jwt-verify.ts no longer cites a middleware file that is
gone.
2026-10-08 18:40:15 +02:00
Maurice 5c054863b9 refactor(server): resolve the data directory in one place
The data and uploads directories were derived independently in about ten
files, each counting `..` hops from its own `__dirname`, and the file
logger used `process.cwd()` instead. Moving a file or starting the server
from another directory quietly moved where secrets, backups or logs went,
and the backup code named `data/travel.db` directly, so with TREK_DB_FILE
set a backup archived and a restore replaced a file the connection never
opened.

`resolveDataPaths()` in app-config now anchors data/, uploads/, backups,
tmp, logs, the JWT secret and encryption key files and the database file
(TREK_DB_FILE when set, else data/travel.db) on the server package root.
Code that runs before the container calls it directly; Nest classes inject
the new `DataPathsService`. Every previous anchor uses it, and the resolved
paths are unchanged for the image and for `npm run dev`.
2026-10-08 18:40:15 +02:00
Maurice c84d0efb1a ci(server): hold unmeasured methods and only SQL errors to the probe baseline
The Postgres probe only ratcheted the methods it managed to call. A method
it could not build arguments for, one that threw before its first
statement and one that timed out were counted and logged but held to
nothing, so a new repository method with a callback parameter could land
SQLite-only SQL unseen, and the part of the code the probe reaches could
shrink without anyone noticing.

pg-probe-baseline.json gains an `uncovered` list of those methods. A
method outside it that goes unmeasured fails the run, and so does an
entry whose method is measured now or gone, so the list only shrinks.
It is seeded from the same first measurement as `failing`; a baseline
without the key reads as unmeasured.

The failing count also counted every refusal, including data errors the
sample values cause (a "probe" string in a date column, SQLSTATE 22007),
so renaming a parameter could fail the gate on a change that never
touched the SQL. Only classes 42 and 0A count now; the rest is reported
in the log and the step summary without being held.

The docs said the probe measures every repository statement. They now
say it measures what each method reaches with sample arguments against
empty tables, and that the methods it cannot reach are held to the list.
2026-10-08 18:39:34 +02:00
Maurice 12bf6c0da3 ci(server): fail the Postgres probe on an unmeasured baseline
The probe shipped with scripts/pg-probe-baseline.json at `failing: null`,
and an unmeasured baseline only reported. Every repository statement
Postgres refused, new SQLite-only SQL included, still passed the job;
only the dialect helper cases could fail it, so the ratchet was off until
some later commit seeded the file.

An unmeasured baseline now fails the run outside --update, and the log,
step summary and annotation say which artifact file to commit. The job
stays red until the first measurement lands, so the gate cannot reach dev
holding nothing. A run that called no repository method (an unreadable
tsconfig, a moved repositories directory) fails as well, also under
--update, so a broken plan can neither pass nor seed an empty baseline.
2026-10-08 18:39:34 +02:00
Maurice b8144bb92a ci: probe the server's SQL against a Postgres service
Nothing in CI ran a statement against anything but SQLite, so the claim
that the dialect layer keeps the repositories portable was never measured
and new SQLite-only SQL could land unseen. The new postgres-probe job
(server/scripts/pg-probe.ts, npm run probe:pg) does that against a
postgres:17 service container:

- it derives a Postgres schema from the entities with MikroORM's schema
  generator, keeping TEXT timestamps, mapping the SQLite-only defaults and
  the NOCASE collation, and leaving foreign keys out;
- it runs every dialect helper on an in-memory SQLite and on Postgres
  against the same expectation, and every case has to pass;
- it calls every public repository method once with arguments the
  TypeScript checker builds from the parameter types, through a Postgres
  driver that runs each statement alone in a rolled-back transaction with
  a statement timeout and records whether Postgres accepted it.

The statements Postgres refuses are held per method to
scripts/pg-probe-baseline.json, which may only shrink. The baseline starts
unmeasured (failing: null): the job reports, passes and uploads the first
measurement as the pg-probe-baseline artifact, together with a JSON report,
so the commit that seeds it and every later commit that lowers it needs no
local Postgres. The probe refuses a database holding tables it did not
create.

@mikro-orm/postgresql joins the server's devDependencies at the 7.2.1 the
other MikroORM packages resolve to; the image's production install leaves
it out. The job needs the preflight gates like every test job, runs only
for the code area, stops after ten minutes and is part of ci-ok.
2026-10-08 18:39:34 +02:00
Maurice 4fa2f3dc8b feat(db): give every dialect helper a Postgres branch
The dialect layer promised that a second engine would be a branch in
src/db/dialect/ and no sweep through the repositories, but every helper
only knew SQLite and threw for anything else. Each one now has a Postgres
branch, and the helpers whose text both engines read alike (COALESCE,
LOWER, TRIM, substr, ||, the aggregates, the CASE shapes) share one
spelling behind isKnownPlatform.

The Postgres branches keep the storage model SQLite has: the clock helpers
render UTC as the same YYYY-MM-DD HH:MM:SS text (to_char over CURRENT_TIMESTAMP
AT TIME ZONE 'UTC'), GLOB becomes an anchored regular expression, the integer
cast truncates through numeric so '14.0' still reads as 14, JULIANDAY
distance comes from the epoch difference, SQLite's null-safe IS becomes IS
NOT DISTINCT FROM, and NOCASE names a nondeterministic ICU collation a
Postgres schema creates. Bound values on the Kysely side are cast to text
where pg would otherwise send an untyped parameter the planner cannot
resolve.

The SQLite branches are untouched, so nothing changes for a running
install. The Postgres text is pinned in sql-functions.postgres.test.ts.
2026-10-08 18:39:34 +02:00
Maurice 53d9ec5678 refactor(db): move the Kysely dialect helpers into kysely-functions.ts
sql-functions.ts sat at 1007 lines, held there by the size baseline, so it
had no room for the Postgres branch every helper is meant to grow. The
Kysely-expression twins are a separate family already (a Kysely Expression
and a MikroORM RawQueryFragment do not convert into each other), so they
move to src/db/dialect/kysely-functions.ts, and the platform check plus the
fail-closed unsupported() move to platform.ts, which both files share.

No helper changes behaviour or spelling. lint:dialect now skips the whole
src/db/dialect/ directory instead of the one file, and the size baseline
drops its sql-functions.ts entry.
2026-10-08 18:39:34 +02:00
Maurice 1d6719347b build(server): count the bare datetime name the query builder takes
The datetime rule only matched a literal holding a call such as
datetime('now'). The query builder spells the same function as
eb.fn('datetime', [...]), where the literal is just the name, so that form
slipped through while strftime, json_extract and glob are caught bare. A
literal that is exactly datetime, julianday or unixepoch now counts as well,
the same way the glob rule treats a bare glob. The only such call lives in the
dialect layer, which is not scanned, so the baseline does not change.
2026-10-08 18:39:34 +02:00
Maurice 6ef6a7efd2 refactor(db): drop the unused raw document provider seeder
seedDocumentProviders() had no caller left: the migration that creates the
tables and DocumentProviderSeeder both import only the DOCUMENT_PROVIDERS and
DOCUMENT_PROVIDER_FIELDS constants. Its two INSERT OR IGNORE statements were
the only SQLite-only spellings in the file and had been baselined rather than
removed. Deleting it lets the file leave the dialect baseline (13 hits down to
11) and the better-sqlite3 import allowlist in eslint.config.mjs, since nothing
in it touches the driver any more. DOCUMENT_PROVIDER_SEED_IDS goes with it, it
had no importer either.
2026-10-08 18:39:34 +02:00
Maurice 6d45a26e46 docs(server): write down the SQL dialect rules
The repositories already lean on the query builder, the generated table
types and sql-functions.ts, but the rules behind that were only implied.
server/CLAUDE.md now states them: which builder call replaces each SQLite
spelling, which helper covers time, strings and casts, where PRAGMA may live,
how a new migration is treated, and that the generated types describe the
SQLite storage model.

It also says plainly that MySQL and MariaDB are out of scope. The
repositories rely on INSERT ... ON CONFLICT and RETURNING, which MySQL lacks,
so Postgres is the second engine the code is prepared for.
2026-10-08 18:39:34 +02:00
Maurice 39bb8f9619 build(server): hold SQLite-only SQL spellings to a shrinking baseline
ESLint keeps raw SQL out of the repositories, but nothing looked at the rest
of src/: the entities, the domain services, the src/db lifecycle files or a
migration added from now on. scripts/dialect-lint.mjs (npm run lint:dialect)
reads every string and template literal under src/, skipping comments, and
counts INSERT OR IGNORE/REPLACE, datetime(), strftime, json_extract, GLOB,
last_insert_rowid, AUTOINCREMENT, PRAGMA outside the lifecycle files, ||
concatenation in SQL and .insertId reads. scripts/dialect-baseline.json
holds today's thirteen hits; the counts may only go down, like lint:size.
The dialect layer is not scanned, and neither are the migrations that have
shipped.

The four seeders that still spelled INSERT OR IGNORE (addons, photo and
document providers, the schema_version marker) are Kysely inserts that do
nothing on conflict now, so a re-run still leaves an operator's rows alone.
They leave the SQL-string exemption list in eslint.config.mjs and their pins
in the selector test.
2026-10-08 18:39:33 +02:00
Maurice c524454f47 refactor(db): read new ids with RETURNING instead of insertId
Kysely fills InsertResult.insertId from SQLite's last_insert_rowid. On
Postgres it is undefined, so the sixteen repository inserts that returned
Number(result.insertId) would hand NaN ids to their services without an
error. They now add .returning('id') and read the id from the row, which
SQLite (3.35 and later, bundled by better-sqlite3) and Postgres both answer
with the same value.

DemoRepository ran its example-trip seed as nine SQL strings through
connection.execute, including INSERT OR IGNORE and a bare CURRENT_TIMESTAMP.
Those are Kysely inserts on the generated table types now: ON CONFLICT
(trip_id, user_id) DO NOTHING for the membership row, the dialect helper for
the timestamp, RETURNING id where the caller needs it. With no SQL text left
it drops off the ESLint exemption list and its pin.

ESLint now refuses any read of insertId in src/db/repositories and
src/db/seeders, so the pattern cannot come back.
2026-10-08 18:39:33 +02:00
Maurice 5f17a5df50 refactor(db): generate the Kysely table types from the migrated schema
The repositories typed their Kysely statements against 159 table interfaces
written by hand, one or more per repository. Nothing compared them with the
schema: a migration that renamed a column or changed its type left them
stale while tsc stayed green, and several had already drifted
(reservations.accommodation_id is TEXT, not INTEGER).

scripts/generate-db-types.ts migrates a throwaway database with the real
migrations, reads every table back and writes one interface per table into
src/db/kysely/ plus the DB map in db.ts. Every hand-written interface is now
Pick<DB, ...> of that type, and npm run check:db-types fails when the
committed files no longer match the migrations.

A column an insert may leave out (rowid, DEFAULT, nullable) is
InsertOptional<T>, declared as ColumnType<T, T | void, T>. The server
compiles without strictNullChecks, where Kysely's own Generated<T> still
demands the column; that is why the old code kept separate insert-only
interfaces. The ids callers pass as number | string are cast to the column
type at the bind, so every statement binds the same value as before.
2026-10-08 18:39:33 +02:00
Maurice ee921d5c28 test(offline): name the v6 upgrade case for what v6 does
Version 6 only adds the driving settings store; dropping the area cache
is version 7. The case name claimed both, so it now names only the new
store and a comment explains where the empty area cache it still checks
comes from.
2026-10-08 18:38:29 +02:00
Maurice 2d2d17619d docs(offline): narrow the rollback promise for newer-format writes
The skip of newer-format queued writes only exists from this release on,
so a rollback to an older release still replays them. On a rolled-back
install the skipped rows also stay pending with no retry or discard path
and hold back later writes to their entity, leaving Clear cache as the
only way out. Say both in the wiki and in client/CLAUDE.md instead of
promising that a rollback is always safe.
2026-10-08 18:38:29 +02:00
Maurice 5049f3762f test(offline): cover every Dexie upgrade path and guard new versions
Each version() of the offline database runs once, on a user's device, when a
new bundle opens what an older one wrote. None of them ran in CI: the v3 blob
cache backfill and the v8 area-fingerprint reset had never been exercised
against an older database.

The new test builds the database as each previous version left it in
fake-indexeddb, seeds a row into every table it had, opens it with the current
schema and checks that the rows survived and that the version did what it is
for. A guard reads the declared versions from offlineDb.ts and fails on a
duplicate, on a version without an upgrade case, or on stores the history
fixture does not reproduce. TrekOfflineDb is exported so the test can open a
named database with it.
2026-10-08 18:38:29 +02:00
Maurice 5bb45bc188 feat(offline): stamp queued writes with their format and skip newer ones on replay
A queued write outlives the bundle that wrote it. After a deploy a tab can
still run the previous bundle, and an install can be rolled back, so a row
written by a newer build may be replayed by an older one that does not know
its shape.

Each row enqueue() writes now carries schemaVersion (MUTATION_SCHEMA_VERSION)
and the UI build that queued it. Both fields are optional, so rows queued
before this read as format 1 and replay as before. A flush leaves a row from a
newer format in the queue without sending, marking or dropping it, and holds
back the later writes to the same entity as it does behind a parked write, so
they keep their order until a build that knows the format replays them.
2026-10-08 18:38:29 +02:00
Maurice 3e1234112d fix(demo): save the first baseline after a reopen that seeded too
The demo seed runs in the schema bootstrap, at boot and again after every
reopen. Since the baseline save moved out of the seed into DemoResetJob,
only the boot read the seed's mark. A restore onto a demo instance with no
baseline, whose archive held no admin trips, seeded the example trips
during the reopen and then saved nothing, so the hourly reset kept logging
"No baseline found"; the mark also stayed set for the rest of the process.
Before the move, that path saved the baseline from inside the seed.

DatabaseLifecycle now runs listeners after a reopen's schema bootstrap,
and DemoResetJob registers one in demo mode that does the same check as at
boot: save through the backup port when the seed just ran and no baseline
exists. Each check clears the mark. A failed save is logged, never turned
into a failed reopen. AdminModule imports DatabaseLifecycleModule from the
shared database kernel for it.
2026-10-08 18:38:29 +02:00
Maurice 1dfc5dd8ea docs(server): say who constructs MaintenanceRepository
The DB paragraph said MaintenanceRepository is never built with `new` in
src/, but MaintenanceModule's factory does exactly that. Say what is
actually true: the module's factory is the one place that builds it, and
every consumer injects it.
2026-10-08 18:38:28 +02:00
Maurice b658b5e68d docs(server): say why the admin domain does not import DatabaseBackupModule
DatabaseBackupModule stays @Global, but its comment gave a vague reason.
The real one is concrete: the backup domain already reaches admin through
auth and settings, so importing the module from AdminModule would add a
domain cycle that lint:boundaries rejects. The module comment now says
that and points at the e2e harness that imports it by hand, and
AdminModule notes where its DATABASE_BACKUP comes from.
2026-10-08 18:38:28 +02:00
Maurice 175f5d96da fix(backup): report a lost connection when the swap and its reopen both fail
SqliteDatabaseBackup.replace reopens the connection in a finally block, so
it runs even when the swap throws. A reopen that failed there was stored
and then dropped: the swap's exception propagated past the return that
would have carried it. The demo reset caught that swap error, logged
"Failed to restore baseline" and returned normally, leaving the instance
without a database connection and nothing in the log to say so. Before
the port, the same branch awaited reinitialize() and its rejection reached
the tick's log line.

replace now throws a DatabaseConnectionLostError when both steps fail. It
carries the swap error and has the reopen failure as its cause, and its
message tells the operator to restart. The demo reset lets that error
through to DemoResetJob's tick, which logs it, and still logs and skips a
failed swap whose reopen succeeded. A restore that hits it answers the
same 500 as any other thrown restore error, with both failures in the
server log.
2026-10-08 18:38:28 +02:00
Maurice fd28ba7fd3 fix(demo): save the first baseline only after a real seed again
Moving the first baseline save out of the boot-time seed into DemoResetJob
also changed when it happened. The seed saved it only on the boot that put
the example trips in; a later boot returned early and saved nothing, so an
instance with data but no baseline logged "No baseline found, skipping" on
every hourly reset. The job instead saved one on any demo boot that found
no travel-baseline.db, freezing whatever the database held at that moment,
user edits included, as the target the hourly reset rolls back to.

The seed now leaves a mark when it seeds the example trips, and the job
saves the first baseline only when that mark is set and no baseline
exists, which is the old trigger. As before, the save does not depend on
the cron registrar being enabled. The demo mode wiki page says so.
2026-10-08 18:38:28 +02:00
Maurice 0aa5b36661 fix(backup): back up and restore through an injected database port
Backup, restore and the demo reset each handled the SQLite file
themselves: backup.impl closed and reinitialized the connection, built
MaintenanceRepository with new, and assumed the database was
data/travel.db. That tied the whole chain to one local file and hid two
bugs.

They now go through DATABASE_BACKUP, a port declared in the shared
database kernel (snapshot, verify, keep a copy before a restore, replace).
SqliteDatabaseBackup implements it with what the code did before:
VACUUM INTO for a consistent copy, and a close, copy, sidecar drop,
rename and reopen through DatabaseLifecycle for a restore. BackupService
injects it and hands it to backup.impl. The demo reset and the baseline
save use the same port; the first baseline is saved by DemoResetJob once
the app is up, because the boot-time seed runs before anything can be
injected. Another engine can plug in a logical dump behind the port.

Fixes on the way:

- The backup and the restore ignored TREK_DB_FILE. The backup looked for
  data/travel.db and, with the database elsewhere, silently left it out
  of the archive; the restore wrote data/travel.db and never replaced
  the live database. Both now use DatabaseLifecycle.file, the path the
  connection runs on, and the pre-restore copy sits next to it.
- A failed VACUUM INTO fell back to archiving the live WAL-mode file
  without a word, the torn copy the snapshot exists to prevent. The
  backup now fails: nothing is archived or committed, the existing
  backups are untouched, a partial snapshot is removed, and the log says
  why. A missing database file and a failed checkpoint are logged too.
2026-10-08 18:38:28 +02:00
Maurice 8ede0dab9e refactor(server): provide MaintenanceRepository through DI
MaintenanceRepository was built with new over an injected EntityManager
in the readiness probe and in UserCleanupService, outside the container.
MaintenanceModule now provides it, as a factory over the same
context-resolving EntityManager Nest injects everywhere, and both inject
it. The class itself stays free of Nest decorators, like the rest of
src/db, so tests keep building one over their own EntityManager.

No behaviour changes: the probe still pings and the erasure still runs
inside the account deletion's transaction.
2026-10-08 18:38:28 +02:00
Maurice 0a4c6fbace refactor(db): open the database through a DatabaseLifecycle provider
db/database.ts opened the connection as a side effect of being imported,
so the moment anything reached it the database was open. index.ts had to
defer loading the app until a first-boot restore was done, and nothing
in the container decided when the connection opened or closed.

The module now opens the connection on first use and opens nothing at
import. A connection closed on purpose (a restore, the shutdown) stays
closed until it is reopened, as before. The new DatabaseLifecycle
provider owns the decisions: buildApp() opens through it, which also
binds the ORM to later swaps (the hook attachOrm used to register) and
runs the schema bootstrap in the same order as before, and the shutdown
closes through it. Its file property is the one resolved database path.

The module keeps every export, so the test suites that replace it with
vi.mock keep working; buildDbMock gains the new openDb.
2026-10-08 18:38:28 +02:00
Maurice 5aa9ac8529 fix(client): compare the running flush with null instead of testing a promise
_flushRun holds the running pass or null. Testing it as a boolean is
correct but reads like a forgotten await, and SonarCloud counts it as a
reliability bug (S6544). The explicit comparison says what is meant.
2026-10-08 14:53:13 +02:00
Maurice 67aff332a7 fix(i18n): translate photos.uploadN as the upload action in every locale
en now reads "Upload {n} photos", but ru, uk, fr and sv still said the
photos had already been uploaded, and the new ru and uk plural forms
copied that wording. ca, es and it used a status noun ("upload of",
"loading of") instead of the action, and ko put the counter in front of
the noun. All locales now name the same action, with each plural form
matching its Intl.PluralRules category.
2026-10-08 14:11:55 +02:00
Maurice e4f39bc8e7 test(server): pin what a send reports while push is unavailable
NotificationsService.send() now reports how many deliveries it attempted
and how many went out, so the reminder jobs can tell a failed send from a
sent one. WPDISP-008 and WPDISP-011 asserted that the send resolved to
undefined, which only meant "no error" and broke on the new result. They
now pin the counts: the in-app row and the webhook go out, and a push the
broken key pair cannot send is skipped rather than counted as failed.
2026-10-08 14:11:55 +02:00
Maurice 65ed033f5d docs(wiki): list the quality gates CI now runs in the dev setup page
CI lints the client with lint:warnings and runs the size, boundary, layering,
offline, skip, format, i18n key and open contract checks, but the dev setup
page still called the client lint:check the CI name and its pre-push list
stopped at typecheck and lint. Add the new scripts to the command tables and
spell out the server, client and shared quality steps from test.yml, so a
contributor who follows the page passes the same gates CI does.
2026-10-08 14:11:54 +02:00
Maurice c8b31b3f57 ci: skip the secret-dependent jobs on Dependabot runs
A run Dependabot triggers only sees Dependabot secrets, and its PRs come
from a branch in this repo, so the fork guard let s3-contract start with an
empty MINIO_LICENSE_KEY. AIStor then never became healthy and ci-ok went red
on every grouped bump PR. s3-contract and scan now also skip when Dependabot
is the actor; ci-ok already counts a skip as passed.
2026-10-08 14:11:54 +02:00
Maurice 306391c7d1 docs(server): count all five rules in the import boundary header
The header said four rules while listing and enforcing five, which also
disagreed with server/CLAUDE.md.
2026-10-08 14:11:54 +02:00
Maurice 3643696a11 ci: treat markdown under test and fixture directories as code
changed-areas skipped every gate for a change that touched only Markdown
outside wiki/. server/tests/fixtures/wiki/ is Markdown too, but wiki.test.ts
parses it, so a PR that only edited those fixtures passed ci-ok without the
test that reads them ever running.
2026-10-08 14:11:54 +02:00
Maurice 6e78342053 docs(wiki): name 5.0 as the release that ships the upgrade safety changes
The image tag rows already say the next release is 5.0.0 and that 4.x gets
no minor tag, while the rollback, email case and APP_VERSION notes still
pointed at a 4.4 that will never be published. Use 5.0 throughout so the
pages agree with each other and with docker.yml.
2026-10-08 14:11:54 +02:00
Maurice 809ec386fc ci(server): fail lint:size and lint:boundaries on stale baseline entries
Both server ratchets only logged an entry the tree had moved past. A
file that shrank could grow back to its old size entry unseen, a
deleted file left its allowance to the next file at its path, and a
boundary violation that was fixed stayed allowed until someone ran
--update. The client ratchets now fail on such entries, so the server
ones do the same.

lint:size fails on an entry above its file's size or for a file that is
gone, and lint:boundaries on an entry that no longer occurs; both tell
to run --update, which still only lowers or drops. Both baselines match
the tree today.
2026-10-08 14:11:54 +02:00
Maurice bd267c67f8 ci(client): fail the count ratchets on an entry above the tree
lint:size, lint:layers, lint:rtl (both baselines), lint:skips and
lint:warnings only failed a count that passed its entry. A count that
fell below its entry was logged and nothing more, so a file that shrank
could grow back to its old entry unseen, and the entry of a deleted
file stayed behind for the next file at that path. lint:format and
lint:offline already fail on such a stale entry, and the docs said
these counts may only shrink.

Each of them now fails on an entry that lowerCounts would lower or
drop, naming its count or saying the file is gone, and tells to run
--update, which still only lowers. staleCounts and reportStale in
lib/ratchet.mjs hold the shared part. Every baseline matches the tree
today, so none needed lowering. The client CLAUDE.md says so, and that
CI lints through lint:warnings rather than lint:check.
2026-10-08 14:11:54 +02:00
Maurice 4344dbe923 test(client): check the planner's note save through its store action
The planner hook tests spy on the store's actions for the visit times,
and the note now goes through a store action as well. Asserting on that
action instead of on the raw note route keeps these tests about the hook
and keeps the file inside its lint:size entry, which the response stub
the route needed had pushed three lines past.
2026-10-08 14:11:54 +02:00
Maurice df2768d30e fix(client): let a flush called mid-pass resolve with that pass
flush() returned at once when this tab already had a flush going, and
that includes one still waiting for another tab's Web Lock. The socket's
reconnect hook chains a refetch onto flush(), so a reconnect landing in
that window rehydrated the trip while a replay was still on its way, the
race the lock wait was meant to close. sendSoon and the online, periodic
and visibility triggers make such an overlap common.

flush() now keeps the running flush as a promise. A second call still
asks it for one more pass and then gets that same promise back, so it
resolves only once every pass, including the one it asked for, is done.
The pass itself moves to _flushPass, and the repeat runs as a loop
instead of a nested flush() call. The client CLAUDE.md sync bullet now
says so, and loses its dash.
2026-10-08 14:11:54 +02:00
Maurice 72c284467b fix(client): cache a visit note saved from the place dialog
The place dialog stopped reloading the days after a save, so the new
visit note only reached the Zustand store. Nothing wrote it to Dexie,
the tab never sees its own assignment:updated echo, and the time save
before it cached the server row with the old note. A trip reopened
offline then showed the old note until the next full sync.

The note now goes through assignmentRepo.setNotes, which caches the
saved row on its day online and queues the write behind any other
write of the same visit offline, like setTimes does. A new store
action applies it, so the page hook no longer patches the store by
hand. usePlaceEdits drops its direct API import and leaves the
offline baseline.
2026-10-08 14:11:54 +02:00
Maurice 9d30a33e78 ci(client): lower the test warning baseline to what the merged branch has 2026-10-08 13:28:34 +02:00
Maurice 5f7d76fd5a ci: run the new size, boundary, layering, offline and i18n gates in CI
Each gate came with its npm script but ran nowhere. The server job now
runs lint:size, lint:boundaries and shared's contracts:open after the
shared build; the client job runs lint:warnings in place of lint:check
(it fails on errors the same way and also holds the warning count per
rule), then lint:format, lint:layers, lint:offline, lint:skips and
lint:i18n-keys. All of them are steps of jobs ci-ok already needs.

Both size baselines are taken on the merged branch. Four server files
grew while the other changes landed (the claimed reminder, the email
uniqueness error and the generator rule for an expression index) and
four client files by two or three lines with the plural forms; one
client file shrank and is lowered.
2026-10-08 13:27:38 +02:00
Maurice 13584d1bf6 style(client): format the i18n key check and its test
The new lint:format ratchet admits no unformatted file outside its
baseline, and these two arrived after it was taken.
2026-10-08 13:27:38 +02:00
Maurice d7563e721d docs(wiki): use a minor tag that will exist as the example
The image tag tables showed mauriceboe/trek:4.3 as the minor tag example,
"published for releases after 4.3.3". The next release is 5.0.0, so no
4.3 tag will ever be pushed, and a self-hoster copying the example into
compose gets "manifest unknown". The example is now 5.0, and the row says
4.x releases have no minor tag and are pinned by major or full version.
2026-10-08 13:04:43 +02:00
Maurice c5ba70f67e ci(release): let a later green ci-ok supersede a red run on the same commit
Both release workflows refused a commit if any ci-ok check run on it was
not green. Every workflow run is its own check suite, so a red run stays
on the commit after a later one passed: a flaky job on the dev push, or a
push run cancelled by a manual run on the same ref (ci-ok runs with
always() and fails on the cancelled jobs). Running Tests & SonarQube
again, as the error told the maintainer to do, added a green suite but
never cleared the red one, and the message did not say which run was
red.

The gate now lives in scripts/ci/ci-ok-verdict.mjs. It fetches every
attempt (filter=all), keeps the newest attempt per check suite, refuses
while one is still going and requires the run that finished last to be
green. It prints the link of every run it counted, and an older red run
it overrode shows up as a notice. A response it cannot read, one cut off
by pagination or a run without a suite, conclusion or completion time
fails the release instead of passing it. Tests run with test:ci-scripts.
2026-10-08 13:04:43 +02:00
Maurice 3f2b60b00b ci: list moved files under their old path, run the tests for chart, compose and wiki changes
The changes job listed the diff with rename detection on, so a file moved
out of an area showed up only under its new path. Moving server code or a
chart template under docs/ turned every area off and ci-ok passed on a
change that removed source from the build. The diff now runs with
--no-renames, and a test reads the exact command out of test.yml and runs
it against a scratch repository with a move in it.

The code area also missed files the test suites read from disk outside
the packages. config-templates.test.ts pins docker-compose.yml and the
Helm chart to the code defaults, help.test.ts serves wiki pages and
images, and the client's help registry test checks its links against the
wiki headings. A chart, compose or wiki change now runs the tests, and a
wiki change also builds the image, which ships wiki/ as the in-app help.
Markdown is still ignored everywhere except under wiki/.
2026-10-08 13:04:43 +02:00
Maurice d88dc17e8e ci(release): build the bump commit, add a minor tag, make the bump an explicit choice
docker.yml built and published whatever `main` pointed to when the build
and merge jobs started, not the commit it had just bumped and tagged, so
anything that landed in between shipped under a version that did not
contain it. build, merge and release-helm now check out the bump commit,
the way docker-dev.yml already pinned its prerelease builds. The push of
that commit fast-forwards main from the commit the run started on, so a
race fails the release instead of skipping over it.

Nothing tied a release to a green CI run. Both release workflows now
refuse a commit unless every ci-ok check run on it has finished green,
and docker.yml refuses to run on anything but main.

bump=auto fell back to a patch whenever no prerelease was in flight,
which would have published the ORM release, the largest change the
project has made, as 4.3.4. auto now only finalizes a prerelease above
the last stable tag and otherwise stops, asking for patch, minor or
major. The inputs are unchanged. A patch that adds migrations gets a
warning, not a refusal, since patch releases have carried them before.

Stable releases also publish :MAJOR.MINOR, so an install can take the
fixes of one minor line without being moved onto the next on day one.
The tag tables in the wiki list it and the prerelease tag, and the root
CLAUDE.md describes the release and prerelease flow.
2026-10-08 13:04:43 +02:00
Maurice 4cfe6b59ba ci: move every workflow off Node 20 actions, pin the third-party ones, add Dependabot
security.yml, lint-prettier.yml and the release workflows still ran
actions built for Node 20 (checkout and setup-node v4, github-script v7,
the docker actions v3/v5, create-github-app-token v1), which GitHub now
forces onto Node 24 with a deprecation warning on every run. They move
to the Node 24 majors test.yml already uses (download-artifact to v7,
since v6 is still Node 20). Third-party actions, several of which hold
the Docker Hub and GitHub App secrets, are pinned by commit with the tag
in a comment. ubuntu-latest moves to a new Ubuntu release on 10-19, so
every job names ubuntu-24.04 and the switch happens in a PR. Every
setup-node reads .nvmrc; publish-plugin-sdk leaves 22, which it ran
under a comment claiming it matched the server.

security.yml gains an npm audit job over the lockfile, on PRs and pushes
into dev as well as main. It covers what the image installs and the
plugin SDK, takes seconds, and would have flagged the brace-expansion
regression the ORM squash brought in the day it landed. Scout keeps its
image scan on main. Neither becomes a required check: a CVE published
overnight would turn every open PR red, including the ones that cannot
fix it. The scout job now also skips PRs into dev, which the trigger
change would otherwise hand it.

.github/dependabot.yml adds weekly version updates into dev for the root
workspace, plugin-sdk, the actions and the Docker base images, minor and
patch grouped per ecosystem, majors one PR each. The Node major in the
Dockerfile is left to a hand-made change together with .nvmrc.
2026-10-08 13:04:43 +02:00
Maurice 8e695cc267 ci: gate merges on one ci-ok check that covers the image and the chart
The ruleset required only `lint` (shared's eslint and prettier) and
`check-target`. Types, lint, coverage, end-to-end and the S3 contract
were advisory, and the three health PRs merged with red checks. Nothing
on a PR into dev built the Dockerfile or rendered the Helm chart either,
which is how #2610 shipped a Dockerfile no build could parse.

ci-ok needs every gate, test, image and chart job and is meant to be the
one check the ruleset requires. It runs with always(), fails when a job
it needs failed or was cancelled, and passes when they passed or were
skipped. The Sonar scan stays outside: its quality gate reports on the
PR by itself, and an outage there should not block merges.

A required check cannot sit behind a workflow-level path filter: on a
docs-only PR the workflow never starts and the check never reports. The
filter moves into a `changes` job. It diffs the PR's merge commit against
its base (or a push against the previous head) and scripts/ci/
changed-areas.mjs maps the files to code, image and deploy areas. It
fails closed: no usable diff, an empty one or a path it cannot read
cleanly runs everything. Its tests run in the same job.

docker-image builds the Dockerfile with buildx (layer cache written by
pushes only, read by PRs), starts it with the same hardening as
docker-compose.yml, waits up to three minutes for /api/health/ready,
checks the VERSION file and that / serves the client, and prints the
container log. deploy-manifests lints the chart, renders it with the
default values and with each secret, persistence and ingress mode,
validates the output with kubeconform, and runs `docker compose config`
on every compose file. helm and kubeconform are pinned by version and
sha256.

While the file is open: every job runs on ubuntu-24.04 instead of the
moving ubuntu-latest, reads its Node version from .nvmrc (the plugin-sdk
jobs ran 22 under a comment saying it matched the server, which runs
24), download-artifact moves from v6, still on Node 20, to v7, and the
Sonar action is pinned by commit.
2026-10-08 13:04:43 +02:00
Maurice 2f5d16ac74 build: pin the Node major in .nvmrc and declare the supported range
Nothing said which Node TREK runs on. The image is node:24, part of CI
ran 22 under comments saying 22 matched the server runtime, and the dev
guide said "22+". A bare-metal or dev install on an unsupported Node
installed without a word and then failed at runtime: isomorphic-dompurify
needs ^22.22.2 or ^24.15.0, the plugin jail's --permission flag is new
in 22.x.

.nvmrc now holds the major the Docker image runs, for nvm/fnm and for
every workflow to read. engines in the root and each workspace states
the range the runtime dependencies accept, the same one
isomorphic-dompurify declares. Without engine-strict npm only warns
outside it, so no existing install stops installing.
2026-10-08 13:04:43 +02:00
Maurice 43ed960f30 fix(deps): lift the vulnerable runtime packages and move the overrides to the root
The image scan has been red since 10-06 on five advisories in four
runtime packages: proxy-addr 2.0.7 (critical, IP spoofing through an
IPv4-mapped IPv6 trust subnet), brace-expansion 2.1.4, source-map-js
1.2.1 and @modelcontextprotocol/sdk 1.30.0. brace-expansion had been
fixed once already; the ORM squash regenerated the lockfile and took it
back to 2.1.4 without anything noticing.

The overrides block in server/package.json was meant to hold such floors,
but npm reads overrides from the root package.json only, so none of them
ever applied: @nestjs/platform-ws kept its own ws 8.21.2 next to the
8.21.3 the block asked for. The floors move to the root, together with
new ones for every package this lifts, so the next lockfile regeneration
cannot quietly undo them. picomatch ^4 is dropped instead of moved: at
the root it would force micromatch's 2.x copy across a major. js-yaml is
lifted only inside its 5.x line, the one @nestjs/swagger pins.

The SDK goes to 1.31.0, the first fixed release; it stays hoisted, which
the CJS path aliases in server/tsconfig.json depend on. dompurify,
fast-uri, ip-address and js-yaml come along because npm audit flags the
same lockfile for them and each fix is inside the range already used.

@mikro-orm/postgresql (with pg), marked and topojson-client are dropped:
nothing in src, tests or scripts imports them, and the ORM config names
only the SQLite driver. The Postgres driver comes back with a Postgres
job that exercises it.

npm audit --omit=dev now reports only maplibre-gl, whose fix is the
6.x major tracked separately.
2026-10-08 13:04:42 +02:00
Maurice 9a7c17e7cc fix(i18n): a quantity under another name than count is a plural group too
The count-string gate only knew {count} and {n}, the params t() picks a
plural form by. A quantity named anything else kept one form in every
language and passed: Russian read "Все {days} дней" for 2 days and
"{objects} объектов" for 3 objects, Greek "{minutes} λεπτά" for 1 minute.

Twelve such en strings become plural groups with their forms in all 27
locales, the quantity renamed to {count}: the trip length limit, the
chat image limit, the API key limit, the storage move prompt and its
reclaimable line, the Dawarich wish rule and suggestion duration, the
road trip lateness warnings, and the road trip echo and window texts.
Their callers pass the count as a number; the storage panels used to
pass it as a string, which no form is picked by. The reclaimable line
loses its dash for a comma in every locale while it is rewritten.

i18n-parity now treats the quantity names in QUANTITY_PARAMS ({days},
{minutes}, {objects}, {max} and the like) like {count}. The four strings
where the number is followed by nothing but a unit symbol (the Dawarich
durations, the upload size limit) go on NOT_PLURAL with that reason.
2026-10-08 13:04:42 +02:00
Maurice c0516dd9bf fix(client): check each literal branch of a template key
A template key was read as a pattern in which every interpolation is a
wildcard, so t(`roadtrip.window.${a ? 'onRoute' : 'onRoutTypo'}`) passed
as long as some other roadtrip.window key existed, and the typo rendered
as raw text.

An interpolation that can only produce string literals (a ternary chain
of them, in parentheses or not) is now expanded, and each resulting key is
checked like a literal one. Thirteen call sites become literal keys that
way. Where data is involved, a literal fallback (`${role ?? 'editor'}`)
is still checked as a key, with the template kept as a pattern under
its own allow-list site. Literals in a condition (`phase === 'start'`)
are never taken for results.

Narrow templates over data still pass without an allow-list entry. The
script header now says plainly what that leaves unchecked: a runtime
value with no key under a narrow prefix, which needs the value's type.
2026-10-08 13:04:42 +02:00
Maurice 5ca6afa789 fix(i18n): the untranslated ratchet sees plural copies and copied phrases
Two holes let English through the ratchet unseen.

A plural form en has no key for (Russian .few and .many, Arabic .two)
was compared with en's value for that same key, which never exists, so a
locale could copy en's text into exactly the forms a new plural group
needs and the count stayed put. Such a form is now compared with every
form en spells out for the group. A form en does declare keeps the exact
comparison: Italian "{count} file" is the Italian plural, not a copy of
en's "{count} file".

The // same-as-en marker excused any value in a Latin-script locale, so
one comment waved a whole copied sentence through. It is honoured only
on a value holding a single plain word, the case shared/CLAUDE.md
already describes. No locale carries the marker today, so nothing that
passed before fails now; the counts are unchanged.
2026-10-08 13:04:42 +02:00
Maurice d4673ca4dd docs(client): say why the dev photos_shared notification sends count as text
The panel fires a test photos_shared event with count: '5', which reads
like the string counts the plural work removed elsewhere. Notification
params are Record<string, string> from the server through the in-app
store, the real sender passes String(added), and resolveTemplate reads a
numeric string as the plural count, so the test event matches what
production sends.
2026-10-08 13:04:42 +02:00
Maurice 33f004ed04 fix(client): a wide template key needs a reviewed allow-list entry
lint:i18n-keys let any template key through as long as its fixed parts
matched one en key, and each interpolation could stand for any run of
characters, dots included. `settings.${mode}` therefore reached all 574
settings keys and `help.guide.${id}.${part}` nearly 2000, so a wrong
suffix behind such a prefix could never fail, and none of the 109
templates had been looked at.

An interpolation now stands for one key segment, and a template passes
by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30)
en keys, the size of an enum such as the docsync error codes. A wider
one fails unless DYNAMIC_ALLOWED has an entry for that file and template
saying what bounds the value. The twelve wide templates in use today
get one each: the help key builders (checked by the registry test),
literal tuples beside the call, typed unions, the generated plugin
permissions and the seeded photo provider fields. An entry whose file no
longer uses the template, or whose template has become narrow enough to
pass alone, fails, so the list stays short and current.

Listing all 109 templates instead was considered and left out: the
narrow ones would only restate their own prefix, and the bound already
keeps a mistake inside one small namespace.
2026-10-08 13:04:42 +02:00
Maurice 5f0865a476 fix(i18n): a one-word English copy counts as untranslated
The ratchet excused any value equal to en that held at most one plain
word, so "Settings", "Appearance", "Readability" or "Users" copied into
another locale were never counted, Japanese and Arabic included. Buttons,
tabs and section titles are mostly one word, so a feature could still add
26 English copies of its labels and stay green.

An identical value is now excused only when nothing ordinary is left
after dropping placeholders, markup, addresses, a listed set of product
names (BRAND_NAMES, which now holds one-word names such as Atlas, Immich
and Mapbox) and unit symbols (UNITS). A word that really is the locale's
own, German "Status" or French "Transport", carries a `// same-as-en`
marker on its declaration, which the catalogue reader now reports. The
marker is honoured only in Latin-script locales: a Latin word left in
Japanese or Russian is English whatever it says. Every locale folder
must be on LATIN_LOCALES or NON_LATIN_LOCALES, so a new language cannot
pass the rule unclassified.

The baseline is re-recorded from the narrower rule: the unmarked copies
go from 1252 to 3238, the marked fallbacks stay at 3157, and no entry
went down. This is the measured state under the new rule, not a raised
gate; translating, or marking a word that is genuinely the same, lowers
it with --update as before.
2026-10-08 13:04:42 +02:00
Maurice 8f691bff5a feat(i18n): untranslated strings per locale may only shrink
Parity proves a locale has every key, not that the key holds the
locale's language: copying the en string passes it, so a feature could
add 26 English copies and stay green. Nothing counted them either. Today
the locales hold 3157 strings marked `// en-fallback` and 1252 unmarked
values identical to en.

scripts/i18n-untranslated.mjs counts both per locale and file and holds
them at scripts/i18n-untranslated-baseline.json, recorded from today's
tree. i18n-parity --strict runs it, so the existing CI step enforces it.
The two counts are separate on purpose: dropping a marker without
translating moves the string from one count to the other and still
fails. --update lowers the baseline to the current counts and never
raises an entry. A missing or malformed baseline, a locale lacking one
of en's files or a value the reader cannot parse is an error, not a
pass.

An identical value is not counted when it reads the same in every
language: after dropping placeholders, markup, URLs, e-mail addresses,
paths and a short list of multi-word names (Google Maps, Home
Assistant), at most one plain word may be left, where acronyms, single
letters and names with an inner capital are not plain words. That
excuses "{count} km", "GPX", "OAuth" and "Budget" and keeps "Source
code" counted.
2026-10-08 13:04:41 +02:00
Maurice bdaddc44ac fix(i18n): every count-bearing string is a plural group
The plural parity check only knows the groups en already declares, so a
"{count} ..." string added as a plain key kept one form in every
language: Polish read "Usuń 1 spakowanych", Czech "Odstranit 1
hotových" and Greek "1 λεπτά". 44 such en keys become plural groups with their
forms in all 27 locales: the collection, packing, day plan and offline
banner counts, the relative times, the audit log, the tour and transit
minutes, the share and photo hints. Labels like "Notes: {count}" get
their forms too, so a language can still inflect them later. The
dashboard's hand-rolled activeOne/activeMany pair, unused, becomes one
group, dashboard.subtitle.active.

i18n-parity now refuses an en string with {count} or {n} that is no
plural group. NOT_PLURAL lists the eleven where the number is never a
quantity (a day number, a step, a waypoint, the equator multiplier),
each with its reason, and an entry that stops matching fails as well.

Callers that worked around the engine:
- Copy to trip switched between two keys on placeIds.length > 1;
  collections.copyN now carries the one-place wording as its `one` form,
  where the language's `one` is the number 1 alone.
- The plugin dependency toasts said "addon(s)" and "plugin(s)"; they are
  groups now and the panels pass the list length as the count.
- The dismissed suggestions count went in as a string and the comp days
  as a formatted string, so no form was picked; both pass numbers.
2026-10-08 13:04:41 +02:00
Maurice f00c354d0d fix(i18n): add the two keys the client named without en having them
BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.

Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.

en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
2026-10-08 13:04:41 +02:00
Maurice 4062b314cf fix(client): lint:skips counts skipIf(true) and runIf(false)
skipIf and runIf were left out as run-time skips, which they are with a
real condition. With the literal true (skipIf) or false (runIf) they
switch a test off for good and gave a free way past the ratchet, while
the same file already counted Playwright's test.skip(true, 'why') for
that reason. Both now count as skips; nothing in the client uses them
today, so the counts stay as they are.
2026-10-08 13:04:41 +02:00
Maurice 1b60de27f4 ci(client): lint:warnings fails an inline rule config comment
A block comment such as /* eslint @typescript-eslint/no-explicit-any: off */
turns a rule off for its file without leaving a message behind, neither
among the warnings nor among the suppressed ones, so a new file could
switch any warning rule off and the ratchet stayed green. eslint-disable
comments were already counted under "suppressed"; this was the way
around them.

lintClient now reads the block comments of every file ESLint linted from
the TypeScript parser, so the same text in a string, a template or a
regex does not count, and the tally reports each config comment as an
error. Nothing in the client uses one today, so the check is zero
tolerance. The tests baseline drops by one no-unused-vars warning that
is gone.
2026-10-08 13:04:41 +02:00
Maurice 9d596d9916 ci(client): lint:size measures the Playwright specs and fixtures too
The size check walked src/ and tests/ only, while lint:format and
lint:skips already cover e2e/. The help center fixtures under
e2e/help/fixtures.ts hold 2291 wrapped lines, past the 2000 a test may
reach, and could grow without bound. e2e/ is now measured with the test
limit; fixtures.ts enters the baseline at its current size, every other
file there is within the limit.
2026-10-08 13:04:41 +02:00
Maurice 3e37dcfb45 fix(client): lint:layers holds src/types.ts to the rules of types
A file's layer was the first path segment under src/, so src/types.ts,
the shared types module every layer imports as '../types', got the
layer "types.ts" and no rule at all, while the imports into it already
resolved to "types". It could import a component or a page unnoticed.
A file directly in src/ is now the layer of its name without the
extension, for the importer and the import alike. No file changes its
count: types.ts imports only @trek/shared today.
2026-10-08 13:04:40 +02:00
Maurice 6a6f3f6cf6 test(client): stub the sync triggers in the consent and dashboard specs
OAuthAuthorizePage reloads the user on mount and useDashboard's retry
reloads it too. The real loadUser then registers the background sync
triggers, a 30 s heartbeat and window listeners that nothing in either
spec unregisters, so they outlive the tests the same way they did in the
account specs before 27db5dd5e. Both specs now stub the triggers, which
neither is about.
2026-10-08 13:04:40 +02:00
Maurice ead327be73 ci(client): lint:format covers the Playwright specs and the client scripts
The check only looked at src/ and tests/, so a new spec under e2e/ or a
new script under scripts/ could land unformatted, and the ratchet scripts
themselves did (fixed in the previous commit). It now walks e2e/ and
scripts/ too and takes .mjs alongside .ts, .tsx and .css. The 58 files
there that were unformatted before this branch (the e2e specs and help
guides, the older build and lint scripts) join the baseline, which keeps
only shrinking; JSON baselines stay out of scope.
2026-10-08 13:04:40 +02:00
Maurice 0344a86aec style(client): format the ratchet scripts with Prettier
The checker scripts were written without semicolons, against the
semi: true in .prettierrc, so every one of them failed the formatting
rule lint:format enforces on src/ and tests/. This is prettier --write
over the scripts this branch added or rewrote, nothing else.
2026-10-08 13:04:40 +02:00
Maurice 17b72ff778 ci(client): lint:layers rules help/ and vacay/ as helper layers
help/ holds the help center's screen registry and vacay/ a few date
helpers; both are plain data and code like utils/, yet without an entry in
RULES they could import any view unseen. Neither does today, so ruling
them costs nothing in the baseline. managed/ stays free and the comment now
says why: it is the slot an operator fills with whole screens at build time.
2026-10-08 13:04:40 +02:00
Maurice ae84f8859f fix(client): lint:skips counts skips with any title and Playwright's fixme
A skip only counted when its first argument was a string or template
literal; anything else was taken for a Playwright run-time condition. So
it.skip(name, fn) in a table loop, describe.skip(TITLE, fn) or
test.skip(true, 'why') switched tests off without the baseline noticing,
and test.fixme was not matched at all.

The condition form is now recognised in Playwright specs under e2e/ only,
and even there a call counts when it declares a test (a body as second
argument), is called bare inside a test or group, or has the literal true
as its condition. Under vitest every .skip counts. fixme is a skip.
Nothing in the tree uses these shapes today, so the baseline stays at 35.
2026-10-08 13:04:40 +02:00
Maurice 9f3802ce2d docs(client): one punctuation fix in the data path rule
The sentence lint:offline extended still joined its clauses with a dash; it reads as one clause now.
2026-10-08 13:04:40 +02:00
Maurice 7ac20a0b59 test(client): pin the exit codes of the ratchet commands
Every ratchet command hands its check to runCli, which sets the exit
code from the check's result and turns a failure of the check itself
(a missing or broken baseline, a directory that is not there) into a
printed FAIL and exit 1. The test runs runCli in a separate node
process, as CI does, and also pins that an unexpected exception still
ends the run with a non-zero code.
2026-10-08 13:04:40 +02:00
Maurice 724cdd1c0e test(client): keep the sign-in background work out of the auth specs
login, completeMfaLogin and register start tripSyncManager.syncAll()
and the system notice fetch without waiting for them, and every
sign-in, loadUser included, registers the sync triggers with their
30 s heartbeat. #2613 stubbed the sync in the push spec after it was
caught logging while the worker shut down ("Closing rpc while
onUserConsoleLog was pending"); the same shape was left in the other
specs that sign in for real.

authStore.test.ts ran the real sync on every successful sign-in and
logged DatabaseClosedError from it, and its notice fetch went past MSW
to the network and logged a failed fetch, both after the tests that
started them. The sync is now stubbed there, and the login, MFA and
register tests assert that it was started. The notice store is reset
before each test, since FE-STORE-AUTH-024 left a stubbed fetch behind.

LoginPage.test.tsx signs in through the form, and AccountTab.test.tsx
and MSettingsAccount.test.tsx reload the user after an MFA change; they
now stub the sync triggers (and the login spec the trip sync), which
none of them is about.

GET /api/system-notices/active gets a default MSW handler returning no
notices, so a login or a mounted notice host anywhere in the suite
settles that fetch in the test instead of on the network. The notice
store's own tests still override it.
2026-10-08 13:04:40 +02:00
Maurice 0f1020cb80 ci(client): lint:skips forbids .only and ratchets skipped tests
JourneyDetailPage.test.tsx has carried 35 describe.skip blocks since
April, for contributors, trip linking and the map view, features that
were reworked rather than removed. Nothing stops the next skip, and
nothing stops a stray .only from passing CI while the rest of its file
never runs.

lint:skips reads every vitest file under src/ and tests/ and every
Playwright spec under e2e/ from its syntax tree. A .only on any runner
fails the check, always. Skipped and todo tests (it.skip, describe.skip,
test.todo, the x-shorthands, skip.each) are counted per file against
scripts/skip-baseline.json, which only lowers. A skip that decides at
run time is not a declaration and is not counted: skipIf, runIf,
Playwright's test.skip(condition, 'why') and a bare test.skip() inside a
test.
2026-10-08 13:04:40 +02:00
Maurice 07f35d8b81 ci(client): lint:warnings ratchets the ESLint warning counts per rule
eslint.config.mjs keeps most rules at warn so that CI stayed green on a
codebase that was never linted, and CI runs eslint without
--max-warnings. Warnings therefore only ever accumulated: 1218 today,
among them 194 unused variables and 144 missing effect dependencies in
app code, the kind behind the stale closures #2610 chased.

lint:warnings runs ESLint over the client with its own config and counts
the warnings per rule, separately for app code and for tests, so the
headroom from fixing a test cannot be spent in app code. Messages that
an eslint-disable comment silenced are counted as a third group, so an
inline disable is no way past the ratchet either. No count may pass its
entry in scripts/eslint-baseline.json, and --update only lowers. Any
error, a parse failure included, fails the check too, so it can replace
the plain lint:check step in CI instead of running ESLint twice.
2026-10-08 13:04:40 +02:00
Maurice 7a7903a799 ci(client): lint:offline stops new views from calling the API directly
The offline core only works when the views go through it: component,
feature hook, store, repo, then api or Dexie, with writes through the
mutation queue. A view that imports src/api/ skips the cache, the
optimistic write and the idempotent replay. client/CLAUDE.md said so
and noted that no ratchet held the line, and the number of view files
importing api/client grew from 161 to 190 in two months.

lint:offline lists the 213 files under components/, mobile/, pages/
and hooks/ that import anything from src/api/ for more than types
(api/client, the websocket, the per-domain modules) in
scripts/offline-baseline.json. No other view file may, and a listed
file that stops leaves the list through --update, so the next file at
that path does not inherit the exemption. Type-only imports do not
count: they carry no request.

client/CLAUDE.md describes the rule next to the data-flow section,
including that an online-only domain puts its calls in a store or repo.
2026-10-08 13:04:40 +02:00
Maurice 798553b13b ci(client): lint:layers holds the import direction between src/ trees
Components reach up into pages/ for models and helpers (28 imports in
25 files: the journey detail helpers, the collections, atlas and
dashboard models) and sideways into mobile/ for phone hooks and sheets
(36 in 30 files), and a few stores and a repo import from components/.
Nothing stopped the next one, and every such import ties two trees
together that should move and split independently.

lint:layers reads each source file's imports from its syntax tree
(multi-line imports, re-exports, import() and type imports included,
nothing inside comments or strings) and checks them against a small
rule table: a component imports neither pages/ nor mobile/, a shared
hook no view, nothing under the views (store, repo, sync, api, db,
utils and the other helpers) any view, a repo no store, and the Dexie
schema none of the layers above it. mobile/ and pages/ stay free, since
the phone screens reuse the page hooks by design.

The 80 imports that break a rule today are counted per file in
scripts/layers-baseline.json, and a file may hold no more than its
entry. Type imports count: a type the lower layer needs belongs below
it. Tests are not checked.
2026-10-08 13:04:39 +02:00
Maurice 37d386178b ci(client): lint:format keeps new client code Prettier-formatted
client/package.json has had a format:check script, but no workflow runs
it, and 2144 of the 2171 .ts, .tsx and .css files under src/ and tests/
differ from what .prettierrc asks for. Without a formatter gate the line
count in lint:size could also be gamed, and every new file picked its
own layout.

Formatting everything in one commit would bury the history, so lint:format
lists today's unformatted files in scripts/format-baseline.json and only
lets the list shrink: a file outside it, any new file in particular, must
be formatted, and a listed file that has been formatted must leave the
list (--update takes it off and never adds one). A stale entry fails the
check, since it would let the next file at that path in unformatted.

The check runs Prettier through its API with the config that applies to
each file and reads the text with LF line ends, so a Windows checkout
agrees with CI about endOfLine. A file Prettier cannot parse counts as
unformatted, and a missing config stops the run.
2026-10-08 13:04:39 +02:00
Maurice 658807b1ac ci(client): lint:rtl fails closed and counts its disable markers
rtl-lint compared its own path with argv[1] to decide whether it was
the command being run. Started through a symlink or with a path that
differs in its form, it printed nothing and exited 0. The check now
lives in scripts/lib/rtl.mjs and the command file always runs it.

A broken rtl-baseline.json read as empty, so --update would have
written {} over it. It now stops the run with its reason, like
size-baseline.json.

The rtl-lint-disable marker skipped a line wherever it appeared, inside
a string literal too, and nothing bounded how often it was used. It is
now honoured only inside a comment, and the marked lines are counted
per file against scripts/rtl-disable-baseline.json. Today that is the
four map overlay positions in PlanMapStage.tsx. A side that is geometry
on purpose still gets its marker, but its entry is raised by hand, where
review sees it, instead of the marker quietly replacing the logical
forms.
2026-10-08 13:04:39 +02:00
Maurice 2f54f04380 ci(client): lint:size counts wrapped lines and covers tests and stylesheets
lint:size counted newlines, so a file could stay under its entry by
joining lines, and the trip planner page already carried one JSX element
on a single 3760 character line. A line now counts once per 120
characters it spans, the printWidth in .prettierrc, so formatted code
measures what it shows and packed code measures what it holds.

The check also covered only .ts and .tsx sources. Stylesheets now share
the 1000 line limit, and tests get a limit of their own at 2000: they
mirror the component they cover and carry fixtures, but the largest are
past 5000 lines and growing with the god components.

The baseline is measured again with the new rule and keyed by the path
from client/, since it now spans src/ and tests/. Entries are higher in
number than before because the metric changed, not because a file grew;
every file is held at exactly what it holds today. New entries are the
ten largest tests, index.css and studio.css, the generated
countryShapes.ts (305 lines of 2600 characters each) and
JourneyDetailPageEntryEditor.tsx, which was just under the old count.

The scanning, baseline reading and lowering move to scripts/lib/ so the
next ratchets share them. A baseline that is missing or misshapen, a
directory that is not there and a scan that finds nothing now fail the
run instead of reading as empty, and the command file always runs its
check: there is no entry point guard left that could skip it.
2026-10-08 13:04:39 +02:00
Maurice 71196b3dad docs: fix the open-shapes spec path and drop two leftover dashes
The open-request-schemas header pointed at scripts/open-request-schemas.spec.ts,
which does not exist; the spec lives in src/. The bracket-notation comment in
the server ESLint config kept a dash that the earlier cleanup on this branch
missed.
2026-10-08 13:04:39 +02:00
Maurice a198dde5d2 test(server): stop counting a filtered partial index as covering a foreign key
The foreign key index check took every index from PRAGMA index_list as a
usable prefix, partial ones included. An index like
CREATE INDEX ix ON c(p_id) WHERE state = 'open' would satisfy FKIDX-001,
yet the cascade lookup WHERE p_id = ? still scans the table, which is the
regression the check exists to catch. A partial index now counts only when
its predicate is IS NOT NULL on the key's own columns, the one form SQLite
can prove from p_id = ?. Nothing in today's schema depends on a partial
index alone, so the result on the migrated snapshot is unchanged.
2026-10-08 13:04:39 +02:00
Maurice 5a5349b817 docs: drop two dashes from the contract and fetch rules touched on this branch
Both lines were reworded here for the new gates and still carried a dash from before. They read the same with a colon and a comma.
2026-10-08 13:04:39 +02:00
Maurice a0e1a39acc ci(server): ratchet the open shapes in every schema a DTO binds
contracts:open in shared only counts exports named *RequestSchema, but
the name is a convention: 37 body DTOs wrap a schema called *BodySchema,
*InputSchema or plain *Schema, and tags.dto.ts declares its schemas
locally. pluginInstanceConfigUpdateSchema (a record of unknown behind
PluginConfigDto), createAlbumLinkSchema with five open shapes and a dozen
more were never counted, and a new open body under one of those names
passed both checks.

tests/unit/contracts/dto-open-shapes.test.ts walks the other way round.
It finds every class that extends createZodDto under src/, imports it,
and counts the open shapes in the schema the Zod pipe validates with
shared's walk. Each class is held to scripts/dto-open-shapes-baseline.json,
61 DTOs with 101 open shapes today, and a new DTO may hold none. It fails
closed: a createZodDto call that is not an exported class, two DTO
classes with one name, an empty tree or an unreadable baseline stop it.
It runs with the unit tests; npm run contracts:dto-open runs it on its
own and --update only lowers the baseline.

The five DTO classes declared inside roadtrip controllers are exported so
the walk can reach their schemas. The selector test picks up prettier's
wrapping of the cases added before.
2026-10-08 13:04:39 +02:00
Maurice 9d6debc591 fix(shared): count an open shape once per path, and see maps, sets and custom
openShapes() skipped every schema node it had already visited, so one
z.unknown() instance reused across four fields counted once, and a
baselined schema could gain any number of open fields that share a node
without its count moving. z.map(), z.set() and z.custom() were not walked
at all, so an open value inside them counted nothing.

An open spot now counts once per path it sits at, and the visited set is
only a guard on the ancestor chain, so a recursive z.lazy() still
terminates. The same kind at the same path is listed once, which keeps the
two sides of an intersection from counting a shared field twice. Map keys
and values and set values are walked, and z.custom()/z.instanceof() count
as a custom open shape. The request schemas hold the same 75 open shapes
under the new walk, so the baseline does not change; the spec covers
reuse, recursion and the new types.
2026-10-08 13:04:39 +02:00
Maurice f357c01c8c test(server): pin the SQL strings in the files exempt from the SQL text rule
MaintenanceRepository, DemoRepository and six seeders are exempt from the
SQL string selectors as whole files, so new SQL text added to any of them
passed lint unseen, and nothing noticed when a file no longer needed its
exemption. The comment promised a list that can only shrink, but nothing
held it to that.

The selector test now works out which files under src/db/repositories and
src/db/seeders the config really exempts and compares that set with a
pinned table, then runs the SQL selectors over each exempt file's source
and compares the hits with the count pinned for it (23 today). One more
statement fails, one fewer asks for the pin to come down, and a file at
zero has to leave the ignores. Per-line disables would have done the same
job but add suppressions the codebase is meant to lose.
2026-10-08 13:04:39 +02:00
Maurice 8487dbb160 fix(server): accept timeoutMs as a fetch deadline and close the null init gap
The outbound-timeout selectors flagged safeFetch(url, undefined,
{ timeoutMs }) and safeFetch(url, { headers }, { timeoutMs }), although
timeoutMs is the deadline over the whole exchange that server/CLAUDE.md
tells callers to pass. At the same time an init spelled null or void 0
passed unseen, so a guarded call could still go without any timeout.

A call whose third argument is an object literal with a timeoutMs key is
now exempt, and undefined, null and void 0 (also behind an as cast) all
count as no init. The cast forms pin the inner node type, because esquery
compares String(value) and a missing name would otherwise read as
'undefined' and flag every cast init. The selector test covers both
sides.
2026-10-08 13:04:39 +02:00
Maurice 30bdcf061b chore(server): spell out the coverage pin and drop a dash from the redirect note
Wording only: the comment over the new coverage entries and the safeFetch doc comment it shares a change with.
2026-10-08 13:04:39 +02:00
Maurice a86aa4e819 ci(server): refuse SQL text in repositories and seeders, move the gallery oracle to tests
The repository rule that keeps dialect SQL in sql-functions.ts only
matched raw() and the sql tag, so a SQL string handed straight to the
connection, em.getConnection().execute('INSERT OR IGNORE ...'), passed
unseen, and the seeders sat outside every repository rule. A new block
refuses a string or template whose text starts like a statement as the
first argument of execute, run, all or get in src/db/repositories and
src/db/seeders; a result mode such as qb.execute('run') stays legal.
The files that already hold such SQL are named one by one so the list
can only shrink: MaintenanceRepository (PRAGMA, VACUUM INTO),
DemoRepository and six of the seven seeders.

The selector groups are named once in eslint.config.mjs, and the
selector test now also checks the new block per path, its exceptions,
and that raw() stays forbidden in repositories.

journey-gallery-order.ts exported raw SQLite ORDER BY text from
src/nest that no production code read any more since the gallery reads
moved onto JourneyPhotosRepository. Two parity tests still compare the
Kysely rebuild against it, so it moves to tests/helpers as their oracle.
2026-10-08 13:04:38 +02:00
Maurice 3fbe0418eb fix(server): bound every SSRF-guarded fetch in time and size
The ESLint rule from #2610 that requires a signal on outbound fetches
only matched the platform fetch. The guarded wrappers hand it a spread
init, which the rule trusts, so safeFetch(url, undefined, options) passed
lint with no timeout, and the dispatcher left undici's five-minute
default in place. Nothing capped the body either. Two callers did exactly
that on hosts TREK does not control: the Synology thumbnail read (a
user-configured NAS) and the place enrichment photo download (a URL out
of third-party place data), both buffering the whole answer.

The wrappers now carry defaults. A call without a signal waits 30 s for
the response headers; the body stays under undici's idle timeout, so a
proxied video that pauses with its player is not cut off. timeoutMs adds
a deadline over the whole exchange and joins the caller's signal, once
for the whole redirect chain. Every body is capped at 64 MB, refused up
front when the declared length is larger and failed mid-read otherwise;
callers pass a tighter maxBytes, or null where the body is streamed on
(pipeAsset, the doc-sync transfers that count their own bytes, the
Ollama pull).

The two call sites get a signal and an 8 MB cap. The lint selectors now
cover safeFetch, safeFetchFollow and safeFetchAdminConfigured as well,
including an explicit undefined init, and are named once so the
repository block restates the same list. A new test asks ESLint for the
options it applies to a path and runs them over snippets, so a selector
that stops matching fails a test instead of passing silently.
2026-10-08 13:04:38 +02:00
Maurice 8e6c30f883 test(server): keep the new gate tests inside the tests' type check
coverage-thresholds.test.ts imported vitest.config.ts, which pulled the
config into tsconfig.tests.json's program; that program compiles as
CommonJS and rejects the config's import.meta, so typecheck:tests
failed. The test now loads the config at runtime. The other new gate
tests get prettier's formatting.
2026-10-08 13:04:38 +02:00
Maurice 5a842c386a test(server): pin the eleven unlisted domains on their own coverage floor
google-quota, managed, mcp-shared, mcp-transport, public-api,
receipt-scan, route-usage, scheduling, school-holidays, tokens and tours
had no entry in the per-domain ratchet, so they sat in the pooled
src/nest/** catch-all, where a domain can lose twenty points behind the
headroom of the files beside it. That includes the cron registrar, the
MCP HTTP transport, the public API and the new tours domain.

Each is pinned at floor(measured) minus 1 as scripts/coverage-thresholds.mjs
prints it, measured over the unit, integration and e2e suites that load
the domain. A full run can only measure higher. mcp-shared (59 branches)
and route-usage (66 branches, 77 functions) measure below the 80 floor
and are pinned where they are; managed holds an empty module.

A new unit test fails when a folder under src/nest has no entry, or an
entry names a folder that is gone, so the next domain cannot land in the
catch-all again.
2026-10-08 13:04:38 +02:00
Maurice 98a446a7c0 test(db): every foreign key has to lead an index
Migration20200101042200 wrote 86 indexes by hand so that deleting a trip
or a user stops scanning every child table while it holds the single
connection. Nothing kept it that way: the next table with an unindexed
trip_id would bring the scans back, and the entity parity tests compare
index names, not whether a key is covered.

The test reads PRAGMA foreign_key_list off the migrated test database
and requires the columns of each foreign key to lead some index or the
primary key. Today every one does, so the allow-list for deliberate
exceptions starts empty, and a second case keeps that list honest. A
third builds a small schema to prove the check fails on an unindexed
key, a composite one, and an index that holds the column second.
2026-10-08 13:02:59 +02:00
Maurice 0b4000af81 ci(shared): ratchet the open shapes in request schemas
The server's boot gate only checks that a write route has a Zod DTO, not
that the schema constrains anything, and 47 of the 213 exported request
schemas hold an open shape: a loose object, a record of unknown, or an
unknown field. shared/CLAUDE.md already says to narrow those rather than
add new ones, but nothing counted them.

scripts/open-request-schemas.mjs walks every exported *RequestSchema and
counts its open shapes against a baseline of today's 75. A new request
schema may hold none and an existing one no more than its entry;
--update only lowers. The CLI checks the built dist/ for CI, and
src/open-request-schemas.spec.ts runs the same walk over src/ so the
shared test run fails as well. A missing or malformed baseline, or a
dist/ without a single request schema, is an error.
2026-10-08 13:02:59 +02:00
Maurice 22b398ad8c ci(server): gate import cycles and cross-domain reach-ins with a baseline
Module boundaries were held by review alone. storage.module.ts documents
a cycle that only surfaced at runtime as "imports[1] is undefined", three
modules exist just to break cycles, and nothing stopped the next feature
from reaching into another domain's helpers.

scripts/import-boundaries.mjs parses every import under src/ with the
TypeScript parser and checks five rules against a baseline of today's
violations: load-time file cycles (none today once type-only and lazy
imports and the two-way MikroORM entity relations are left out), new
dependencies between domains that already depend on each other (262
pairs, one tangle around plugins and auth), shared-kernel files importing
a domain (8), imports of another domain's internals (69) and src/db
importing src/nest (5). A domain's public surface is what the code
already treats that way: modules, services, guards, decorators, types,
constants, the audit logger, the plugin RPC kit and the addon gates.

New violations fail, --update only drops entries, and an unreadable
baseline, a missing src/ or an import it cannot resolve stops the run.
2026-10-08 13:02:59 +02:00
Maurice e5a5d167af ci(server): hold source files to 1000 lines with a size baseline
The client has had lint:size since #2612, the server had nothing, and
the server is where the biggest files live: maps.service.ts holds 3231
lines and grew by 200 in a month, and nine more services are past 1100.
scripts/size-lint.mjs carries the client's semantics over to src/ and
scripts/: a file may hold 1000 lines, the 23 that already hold more are
pinned at today's length in scripts/size-baseline.json, and --update
only ever lowers an entry.

A line past prettier's 120 columns counts once per 120 columns it spans,
so joining lines does not make room under the limit. The check fails
closed: a missing or malformed baseline or a missing src/ stops it
instead of passing with nothing checked.
2026-10-08 13:02:59 +02:00
Maurice 789a95de92 fix(planner): the place dialog and the reorder time removal go through the visit repo
The planner's two time edits of a visit called the time route straight:
saving the place dialog opened from a visit, and removing a visit's time
before a reorder. They skipped the visit repo's check for an older write
to the same visit still in the queue, so with such a write parked or
backing off the new time landed first and Try again (or the next flush)
replayed the older time over it, with no version token to refuse it.

Both now use the store's setAssignmentTimes, which writes through
assignmentRepo.setTimes, queues behind the older write when there is one
and keeps the optimistic update and rollback the other time edits have.
The dialog no longer reloads the days afterwards, since the store already
holds the new time and a reload would show the server's older one while
the new one waits in the queue; a changed day note is set on the visit in
place instead. The retry docs drop the exception they made for these two
paths.
2026-10-08 13:02:58 +02:00
Maurice c63ec5e285 fix(offline): a flush asked for during a running pass gets its own pass
An online edit that has to queue behind an older write to the same entity
starts a flush so it goes out right after that write. When the older write
was already in flight, flush() returned at once on the in-tab flag, and the
running pass had read its pending rows before the new one existed. The edit
then sat in the queue until the next trigger (the 30 second tick, a
visibility change or a reconnect), so collaborators saw it late and a
refresh in that window showed the older server value.

A flush called while a pass runs now asks that pass for one more round,
which it starts once it is done, next to the existing "mine wins" re-run.
Each pass clears the request when it starts, so the extra rounds stay
bounded by the calls made during a pass.
2026-10-08 13:02:58 +02:00
Maurice 48b1e879d2 fix(offline): an online edit waits behind an older queued write to the same entity
Holding later queued writes behind a parked one only ordered writes that
were in the queue. Online, the repos send an edit straight to the server, so
an edit made while an older write to the same visit, tour or driving settings
was backing off or parked landed first, and Try again (or the next flush)
then replayed the older write over it. Those writes carry no version token,
so nothing refused the stale replay.

The repos of visits, day clears, tours and driving settings now ask the
queue before an online write. While an older write to the same entity is
pending, in flight, parked or a conflict, the new write is queued too, with
the same optimistic Dexie write as offline, and a flush is started so it
goes out as soon as the one ahead of it does. When the driving settings left
the cache since the older write, they are read in first.

Places and packing items keep their direct online path: they send the base
token, so a stale replay is refused with a 409 and becomes a conflict. What
this does not cover, and the docs now say so: a collaborator's change to a
token-less entity is overwritten by the retried write, as by any replayed
offline write of those resources, and the planner's two time edits that call
the API directly are not held. The wording of the parking commit also named
budget items, which never go through the queue.
2026-10-08 13:02:58 +02:00
Maurice b61a55f037 fix(offline): archived trips on the device follow the eviction rules again
Keeping archived trips stopped the sync from wiping them, but left nothing
that ever took one off the device. The trip sync never refreshes an archived
trip, and the 7-day rule and the per-trip switch only looked at the active
list, so an archived trip stayed until Clear cache however long ago it ended.

The stale and switched-off rules now also run over the archived trips that
are stored here, with the same eviction as an active trip: its rows go, its
parked changes stay. The offline settings list the stored trips the server's
list leaves out, so an archived trip gets a switch the user can turn off;
before, the switches came from the active list only and could not reach it.
2026-10-08 13:02:58 +02:00
Maurice e132d1529f fix(offline): a tab waits for the flush lock instead of skipping its flush
The cross-tab lock was taken with ifAvailable, so a tab that found another
tab replaying returned from flush() at once. Its callers read that as "the
queue is sent": the online trigger then re-seeds every trip from the server
and rehydrates the open ones, and the socket refetches after its pre-reconnect
flush. All tabs see the online event together, so the tabs that lost the race
laid the server's rows from before the replay over the shared Dexie, and over
the optimistic edits still on their way, while the winning tab was mid-flush.

The lock is now requested without ifAvailable. A tab that finds it held waits,
then runs its own pass, which finds nothing or what the holder left behind,
so flush() resolves only after the replay and the re-seed chained onto it
reads the state after it. The holder's requests are bounded by the client
timeout, so the wait is too.
2026-10-08 13:02:58 +02:00
Maurice f5339307b2 fix(offline): only one tab flushes the queue at a time
Every open tab shares the account's queue and runs its own flush
triggers, and the _flushing flag only guards its own tab. Two tabs read
the same pending rows and both replayed them, so correctness rested on
the server's replay cache answering the second request, and each tab ran
the temp id remap and the Dexie write a second time.

The flush now takes a Web Lock named after the account's database, with
ifAvailable, so a tab that finds it taken skips the run the way a busy
flush in the same tab always has; the holder picks the rows up. The lock
is released with the tab, so a tab killed mid-flush cannot strand it.
Where the API is missing or refuses the request, the flush runs as
before under the flag alone.
2026-10-08 13:02:58 +02:00
Maurice b4f705a049 fix(offline): a parked write holds back the later writes to the same entity
Parking a write the server keeps failing on unblocked its trip, so a later
queued edit of the same budget item, visit, tour or driving settings went
through while the older one waited. Try again then replayed the older
write last and it silently overwrote the newer one: only places and
packing items send a token the server could refuse it with. Before the
parking change a 5xx stopped the whole flush, so this order was never at
risk.

The flush now holds back every pending write whose entity has an older
write parked as failed or waiting as a conflict, and a write that parks
during the pass holds back the rest of that pass too. Other entities and
other trips keep syncing. Try again therefore replays the parked write
first and the held ones after it, Keep mine does the same for a conflict,
and Discard or Keep theirs flush right away so the held writes go out
without waiting for the next trigger. Writes queued against an offline
create that got parked now wait for it instead of failing as an
unresolved temp id.
2026-10-08 13:02:58 +02:00
Maurice 237f82c0b6 fix(offline): an archived trip is not a deleted one, and eviction keeps parked changes
The trip sync clears every cached trip the server no longer lists, but it
read only GET /trips, which leaves archived trips out. Every sync (login,
reconnect, the periodic run) therefore wiped every archived trip from the
device, pinned ones included, and with it their parked failed changes. A
trip now only counts as gone when it is missing from both the active and
the archived list, and when the archived list cannot be read nothing is
treated as gone that round.

clearTripData no longer deletes 'failed' rows. Stale eviction, a trip
switched off in the storage settings and a trip that is really gone all
go through it, and none of them is the user deciding to give up a change.
A parked change of a deleted trip, or one the user was removed from, now
stays in the queue: Settings > Offline counts it under failed changes and
offers Try again (the server answers 403/404, so it parks again) or
Discard. Dropping it silently would lose the only copy of an edit the
user may still want to look at or redo elsewhere, and keeping it costs
nothing more than a counter the user can clear in one tap.
2026-10-08 13:02:58 +02:00
Maurice 7cb4a09837 docs(wiki): say which images refuse a newer database on rollback
The rollback section promised that an older image refuses a migrated
database and names the pre-migrate copy. Only images from 4.4 on carry
that check. Going back to 4.3.x, the most common rollback today, gives
neither the refusal nor the pointer, so the copy has to be restored by
hand before the old image starts.
2026-10-08 13:02:58 +02:00
Maurice 28f6d30368 docs(notifications): why the reminder backfill counts days in UTC
The backfill in Migration20200101042100 marks reminders as sent by UTC
date('now'), while the reworked job counts days in the app TZ, which read
like a seam that sends twice or skips on upgrade day. It is not: the
backfill stands in for the job before it, which matched start_date
against UTC date('now') at 9 AM app time, so UTC is the clock those
reminders went out by, and with one run per UTC day nothing it marks
was left unsent. The only effect is its strict "<": the upgrade day's
own reminders stay unmarked because the boot may precede that day's
run, so a boot after it reminds such a trip once more. One extra
reminder at most and never a lost one, so the shipped migration stays
untouched and the reasoning now sits on the job.

The todo claim's docstring writes its dedup bound as SQL.
2026-10-08 13:02:58 +02:00
Maurice bb5cc8f795 fix(db): one account per email address, whatever its case
Login, password reset and the sign-up checks all match emails
case-insensitively, but the only constraint was the column's own
UNIQUE, which SQLite compares case-sensitively. Two accounts whose
addresses differ only in case (legacy rows, two sign-ups at once) left a
login to resolve to whichever row came first, and LOWER(email) lookups
had no index to use.

A migration adds a unique index on lower(email). An install that
already holds such a pair must still start, so the migration then
leaves the index out, logs how many addresses are shared and by which
user ids (not the addresses), and records email_case_index_pending.
Since a migration runs once, db/email-case-index.ts runs on every boot
while that flag is set: it repeats the warning until the accounts are
told apart, then creates the index and clears the flag. The app-level
checks keep guarding meanwhile.

A write that passes the up-front check and still loses the race is now
refused by the database. The users repository turns that unique
violation into UserIdentityTakenError, and sign-up, admin create and
edit, and the profile update answer it with the same 409 their checks
give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned
the old behaviour and now pins the 409).

The entity generator could not see an expression index (PRAGMA
index_info names its column NULL), so PARITY-009c found a db index with
no entity declaration. Rule 14 carries such an index into the entity as
a named entry with its own statement; Users.entity.ts is regenerated.
2026-10-08 13:02:58 +02:00
Maurice c04fca81ee fix(notifications): claim a reminder before sending it, give it back when sending fails
The trip and todo reminder ticks sent with `.catch(() => {})` and then
marked the reminder sent whatever happened. A send that threw (a DB error
resolving the recipients) or whose every channel failed (SMTP down, no
in-app) was recorded as delivered and never retried, without a log line.
And because the mark came after the send, two processes running the same
tick (a rolling update overlap, a stray container on the same volume)
both sent.

Each reminder is now claimed first with one conditional UPDATE through
its repository: reminder_sent_for is set only while it differs from the
start date (and the start date is still the one read), reminded_at only
while it is empty or older than the 20 hour dedup window. Only the
process whose update lands sends. If the send throws, or it attempted
in-app and channels and none went out, the claim is given back, the
failure is logged, and the next tick tries again until the trip starts
or the todo falls out of its window. A send nobody wanted (everyone
opted out) stays marked, since a retry would reach no one.

For that, NotificationsService.send() now reports what it attempted and
what was delivered; a channel that rejects or answers false counts as
not delivered. Existing callers ignore the result.
2026-10-08 13:02:57 +02:00
Maurice f527b933fb test(db): boot a v4.3.3 install with real rows on this release
The legacy-upgrade suites stopped at legacy-v242, while v4.3.3, the
release every 4.x install will come from, left schema_version 244. The
most common upgrade path only ever ran against synthetic step maps.

legacy-v244.sql is that release's own runner booted once on an empty
file, plus synthetic rows across the main tables: users (one with a
mixed-case email), trips with a member, days, places, assignments with
the canary times, a booked stay, a flight with endpoints, budget items
with splits, packing, todos, notes, file metadata, tags, a journey with
an entry and a notification. scripts/build-legacy-fixture-v244.mjs
rebuilds it byte for byte from the tag: it archives v4.3.3 into a temp
dir outside the repo, boots that runner, seeds, pins every timestamp,
VACUUMs and dumps.

The suite runs the shared upgrade assertions and checks that every row
of those tables comes through with its values, that the boot copied the
database first and the copy still is the 4.3.3 schema, and that the
reminder backfill marks only the reminders whose day has passed.
2026-10-08 13:02:57 +02:00
Maurice ff3615fdf0 feat(db): copy the database before a boot migrates it
Migrations only go forward, and since #2610 an older image refuses a
database a newer one migrated. Going back after a bad update therefore
needs a copy from before it, and the only copies were backups the admin
takes by hand or auto-backup, which is off by default. The first boot
on 4.4 migrates every 4.3.x install through the legacy baseline and the
new migrations without either.

Before applying pending migrations to a file database that holds
anything, the boot now writes a VACUUM INTO copy next to it, named
pre-migrate-<schema>-<time>.db (legacy-<schema_version> for an install
the positional runner left, otherwise the last recorded migration), and
keeps the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP (3). The copy is
written under a temporary name and renamed, so a crash never leaves a
truncated file that looks good. If it cannot be written the boot refuses
before anything is migrated; TREK_DB_PRE_MIGRATE_SNAPSHOT=false is the
documented way past that. The newer-database refusal names the copy
taken right before the newer release migrated past this one.

A fresh install, a restart with nothing pending, an in-memory database
and a restore's re-bootstrap (the archive is the copy) take none, and
NODE_ENV=test leaves it off unless a suite sets the switch.
2026-10-08 13:02:57 +02:00
Maurice 395a7f41fe test(client): keep the login's background trip sync out of the push spec
A successful login starts tripSyncManager.syncAll() without waiting for
it. In authStore.push.test.ts that sync was real and could still be
logging when the worker shut down, which vitest reports as an unhandled
"Closing rpc while onUserConsoleLog was pending" and fails the coverage
job although every test passed. The spec is about push, so the sync is
stubbed.
2026-10-08 11:04:33 +02:00
Maurice 8997640ac9 ci(sonar): give the JS/TS analyser 8 GB on full scans
Since the ORM migration the scan of every push to dev stops with "the
Node.js process running out of memory (heap size limit 4288 MB)" and
fails the job, while PR scans, which only analyse changed files, stay
under it. The hosted runner has 16 GB.
2026-10-08 11:04:33 +02:00
Maurice 7de50c5d57 fix(docker): put the escaped newline back into the VERSION printf
c6b6b83d3 wrote a real line break where `printf '%s\n'` belongs, so the
RUN instruction ended inside the quote and Docker read the next line as an
instruction of its own: "dockerfile parse error on line 135: unknown
instruction: '". Every image build from dev fails on it. PR CI builds no
image, and the scout job on the release PR was already red for another
reason, so nothing showed it.
2026-10-08 11:04:33 +02:00
Maurice 39fa796503 ci(client): the split files leave the size baseline
useTripPlanner.ts (3095 lines, now 718) and RoadtripSidebar.tsx (2287, now
694) are back under the limit and drop out of the baseline, and
TripPlannerPage.tsx is held at its new 1081 lines.
2026-10-08 05:02:48 +00:00
Maurice 9b9ec44b5e refactor(client): booking saves and the import review move into useBookingEdits
Saving a reservation or a transport, deleting a booking, finding or
creating the place an imported stay belongs to, the review that walks
imported items through their editors with its state, and the bridge
that picks up an import or a scanned receipt from the background tasks
move out of useTripPlanner into useBookingEdits.

useTripPlanner calls the hook where the save handlers used to sit,
after the day add, delete and clear wiring and before the booking
detail, so the bridge still runs between the rail snapshot and the
splash and the planner returns the same object. The review state only
moves later in the render, which no effect observes. The two existing
loose record types in the save handlers move along unchanged.
2026-10-08 05:02:48 +00:00
Maurice f9d0f960bc refactor(client): the day plan's assignment edits move into useDayPlanEdits
Putting a place on a day, moving a stop to another day, taking one off,
reordering a day with the vias it carries along, renaming a day and
reordering the days move out of useTripPlanner into useDayPlanEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
day plan's edits used to sit, ahead of the day add, delete and clear
wiring, so the planner returns the same object and behaves exactly as
before.
2026-10-08 05:02:48 +00:00
Maurice 7e6af9c8fc refactor(client): the ways into the place form and the stop popup move into usePlaceFormOpeners
The right click on the map, the POI marker and the hit on the drive with
the check-out days a night can end on, the service stop added by hand
with the legs it can go on, the popup handing over to the full form with
its duplicate check, and the place editor move out of useTripPlanner into
usePlaceFormOpeners.

None of it runs an effect, and useTripPlanner calls the hook right after
the place edits, where the place editor used to sit, so the planner
returns the same object and behaves exactly as before. The note on adding
a POI with its day now sits directly above the handler it describes.
2026-10-08 05:02:48 +00:00
Maurice a3fed9e392 refactor(client): saving, deleting and recategorising places move into usePlaceEdits
The place delete question with the booked nights it warns about, telling a
tour from a place, saving the place form with the day it was added from
and the vias that stop pushes along, deleting one place, a tour or several
places, and moving places to another category move out of useTripPlanner
into usePlaceEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
place form's save used to sit, ahead of everything that asks whether a
place is a tour, so the planner returns the same object and behaves
exactly as before.
2026-10-08 05:02:47 +00:00
Maurice b771eae61b refactor(client): the via anchoring, refuel stops and dropped hits move into useRoadtripDriveShaping
Working out which stop a point on the drive belongs behind, where a place
chosen by hand goes, adding, dragging and removing vias, asking for and
accepting a refuel stop, and a corridor hit dropped onto the route move
out of useTripPlanner into useRoadtripDriveShaping.

None of it runs an effect, and useTripPlanner calls the hook where the via
anchoring used to sit, so the planner returns the same object and behaves
exactly as before. The note on placing a via now sits directly above the
function it describes.
2026-10-08 05:02:47 +00:00
Maurice d2706d3d1e refactor(client): the route alternatives picker and the map framing move into useRoadtripAlternatives
The other ways of driving one leg, the road the pointer is on, the
overlays and the stretch of map they cover, the automatic day points and
their markers, the point the map is asked to bring into view, the rail
snapshot a choice is checked against, and asking for and choosing an
alternative move out of useTripPlanner into useRoadtripAlternatives.

useTripPlanner calls the hook where the picker used to sit, after the
road trip feed and the stop edits, so the highlight reset, the close gate
and the rail snapshot still run in the same order and the planner returns
the same object. The note on what the map frames now sits directly above
the value it describes.
2026-10-08 05:02:47 +00:00
Maurice 9681fdf321 refactor(client): the edits to one stop on the drive move into useRoadtripStopEdits
Saving the stop popup as a pause or as a night, the stay release that
asks before a booked night is dropped, the kind, fill and stay of a stop,
moving a stop within its day or onto another one from the rail, ending
the day at a stop, and the stay dialog with the call that opens it move
out of useTripPlanner into useRoadtripStopEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
stop edits used to sit, so the planner returns the same object and
behaves exactly as before.
2026-10-08 05:02:47 +00:00
Maurice bde734c50f refactor(client): the road trip feed and the layers it draws move into useRoadtripFeed
The road trip mode and feed flags, the driving preferences and the limit
save, the day boundaries with their reset and drag controls, the vias,
the refuel search, the routes, the corridor search, the followed track,
the via counts and the via index helpers, the days folded in the rail and
the map layers that follow those folds move out of useTripPlanner into
useRoadtripFeed.

useTripPlanner calls the hook where the feed used to sit, right after the
trip overview, so the preference load, the boundary and via hooks, the
routing round and the corridor search still run in the same order and the
planner returns the same object. The helpers, the folds and the boundary
controls are plain memos and callbacks, so computing them earlier changes
nothing.
2026-10-08 05:02:47 +00:00
Maurice 1db227cb5b refactor(client): the map marker set and the day order badges move into useMapPlaces
The list filters the map shares with the sidebars, the declutter of
collapsed days, the compact and pending marker flags, the Tracks filter
fallback, and the selected day's order badges and fit list move out of
useTripPlanner into useMapPlaces.

useTripPlanner calls the hook where the marker set used to sit, right
after the trip's websocket, so the store subscriptions and the Tracks
fallback still run in the same order and the planner returns the same
object. The order badges and the fit list are plain memos, so computing
them earlier changes nothing.
2026-10-08 05:02:47 +00:00
Maurice 41dabbda6e refactor(client): the planner map view toggles and first fit move into usePlannerMapView
The day route toggle and its travel profile, the whole-trip overview, the
map lock, the recorded Dawarich trail, the fit key and the effect that
fits each trip once move out of useTripPlanner into usePlannerMapView.
A new test calls every toggle and the automatic route, which no test
reached before.

useTripPlanner calls the hook where that code used to sit, so the
Dawarich fetch and the first fit still run in the same order and the
planner returns the same object. handleSelectDay now lists the map lock
ref, the phone ref and setFitKey it receives from the hook; they never
change identity, so nothing behaves differently.
2026-10-08 05:02:47 +00:00
Maurice 87ca3df781 refactor(client): the planner dialogs and their URL intents get their own hook
The place form, the service stop form, the stop popup, the stay release
question, the trip and member dialogs, the booking, transport and transit
editors, the importers and the booking detail move out of useTripPlanner
into usePlannerDialogs, together with the ?create= and ?tab= effects and
the AirTrail sync. plannerTypes.ts holds the option types the planner's
sub-hooks share.

useTripPlanner calls the new hook where that code used to sit, so every
effect still runs in the same order and the planner returns the same
object. The state setters it now receives from the hook are added to the
dependency lists that use them; they never change identity, so nothing
behaves differently.
2026-10-08 05:02:47 +00:00
Maurice e2dab1aebb refactor(client): the trip summary and the day header move to RoadtripSidebarHeaders
TripSummary leaves RoadtripSidebar.tsx for its own module and is
imported back by name; Unit and splitValue go with it unexported.

The day card's header becomes DayHeader in the same module. Its body is
the header element the card drew inline, together with the reads only
the header used: the language, the distance unit, the clock format,
the missed ride and the day colour. The card keeps its translation and
passes the day, the fold state and the track details straight through.
DayHeader returns the bare header with nothing around it, so the markup
is unchanged, and it sits at module scope so it never remounts.

The code moves as it was otherwise, with the dashes in the moved
comments rewritten as colons and commas, and the imports only these
two used leave the sidebar, which is down to about 700 lines. Nothing
renders differently.

New tests render both on their own: the summary's figures split from
their units, narrow and still loading; the day's facts and its fold
control; a day over the driving limit, roads it could not avoid, a
missed flight, the track badge with and without its name, and the
wash in the day's own colour.
2026-10-08 05:02:47 +00:00
Maurice 8e2a2725f0 refactor(client): the stop rows move to RoadtripSidebarStopRows and share one badge row
ServiceStop, RouteViaStop and Stop leave RoadtripSidebar.tsx for their
own module and are imported back by name; effectiveFill goes with them
unexported, and the imports only they used leave the sidebar.

The badge row under a stop's name was written out twice, once in each
stop shape. It is now the local StopBadgeRow, which both render in its
place. It returns the same span with nothing around it, so the markup
is unchanged, and it reads the fill setting and the vehicle the two
shapes used to read themselves. Those are plain store reads, so moving
them one component down changes nothing either.

The code moves as it was otherwise. The note on the stop kind table now
sits at the lookup it describes, the comment on the two settings says
where they are read now, and the dashes in the moved comments become
colons, commas and full stops; each theme-lint-disable stays on the
line of its style. Nothing renders differently.

New tests render the rows on their own: a plugin halt with and without
its name and stay, the kind picker on a pause and on a numbered stop by
click, Enter and Space, the drive findings and the walk from the road
on both shapes, including the range and unknown findings the rail never
draws, and the fill badge with the traveller's default.
2026-10-08 05:02:47 +00:00
Maurice 5cfc546630 refactor(client): the rows a booking stands for move to RoadtripSidebarBookingRows
TerminalStop, BookendStop, RideBlock, BookingChips and UndatedRides
leave RoadtripSidebar.tsx for their own module and are imported back by
name. RIDE_DASH, DiscRow, CodeChip, TimetableClock and CheckInBadge go
with them unexported, since only these rows use them, and the imports
only they used leave the sidebar. The code moves as it was; the one
change is that UndatedRides spells out the type of its booking callback
instead of reading it off the sidebar's props, so the new module never
imports from the sidebar. Nothing renders differently.

New tests render each row on its own: a departure with its clock and
check-in, a hire desk, a booked night that is left after check-out, a
ride that is caught, missed or has nothing on its timetable, the chips
a reader may and may not open, and the rides on no day.
2026-10-08 05:02:47 +00:00
Maurice 2552b37e90 refactor(client): the drive band and the night drive block move to RoadtripSidebarDriveBands
DriveBand and SpillBlock leave RoadtripSidebar.tsx for their own module
and are imported back by name. MODE_ICON goes with them, unexported,
since only the drive band reads it, and the imports only these two used
leave the sidebar. The code moves as it was, with the dashes in its
comments rewritten as colons, commas and full stops. Nothing renders
differently.

New tests render both on their own: every travel mode's sign, the
alternatives button and its pressed state, a hop, a ride with and
without its booking and timetable, and a night drive that is automatic,
has no leg yet or misses its figures.
2026-10-08 05:02:47 +00:00
Maurice 8e4874841f refactor(client): the fill-up band moves to RoadtripSidebarRefuelBand
RefuelBand leaves RoadtripSidebar.tsx for its own module and is
imported back by name; the imports only it used go with it. The code
moves as it was, with the dashes in its comments rewritten as colons
and parentheses. DriveBand's note, which the band used to separate from
its function, now sits directly above DriveBand. Nothing renders
differently.

New tests render the band on its own: idle, searching, offers with and
without the right to add one, each empty answer, and the battery words
for an electric car, which no existing test reached.
2026-10-08 05:02:47 +00:00
Maurice 97a93bb719 refactor(client): the stop badges and the arrival clock move to RoadtripSidebarBadges
OffRoadBadge, FillBadge, NightCheckIn, StayBadge, DriveFindingBadge,
DayCarry, Arrival and LateBadge leave RoadtripSidebar.tsx for their own
module and are imported back by name. The code moves as it was. The doc
blocks that sat stranded above other functions now stand above the
badge they describe, StayBadge's two notes as one, and the dashes in
the moved comments become colons and commas. Nothing renders
differently.

New tests render each badge on its own, including the walk from the
road and the range and unknown arms of the drive finding, which the
rail never reaches.
2026-10-08 05:02:47 +00:00
Maurice 7bd351f9b7 refactor(client): the rail's shared styles move to RoadtripSidebar.constants
RAIL_GRID, RAIL_DASH, DISC, STAT_LABEL and DAY_BADGE leave
RoadtripSidebar.tsx for a plain module beside it, so the parts the
sidebar is split into next can share them without importing from the
sidebar itself. Values and comments move as they were, except that the
dash in the DISC note becomes a colon; the two style objects are typed
as CSSProperties from a type import. Nothing renders differently.
2026-10-08 05:02:47 +00:00
Maurice b8c1fbf192 refactor(client): the plan map and its floating controls move out of the planner page
The plan tab's MapView with the route alternatives bar, the overview and
trail stack, the POI and compass cluster, the phone compass and the phone
POI portal move to pages/tripPlanner/PlanMapStage.tsx. It returns the same
children as a fragment in the same place, so the DOM and the portal order
stay as they were. Its props are picked from the planner hook's own types.

The four physical sides that moved are map geometry and carry an
rtl-lint-disable marker on their line, so the page's rtl baseline drops
from 17 to 13. Two moved comments lose a dash.
2026-10-08 05:02:47 +00:00
Maurice 7d8dcb02c8 refactor(client): the lists tab moves out of the planner page
ListsContainer, the packing and to-do subtabs of the lists tab, moves
unchanged to pages/tripPlanner/ListsContainer.tsx, taking along the
imports only it used. The page renders it exactly as before.
2026-10-08 05:02:47 +00:00
Maurice 3b9e5606ce refactor(client): the planner's lazy panels and their boundary get modules of their own
The lazyWithRetry declarations of the planner page move to
pages/tripPlanner/plannerLazy.ts, a plain module so Fast Refresh keeps
treating the page as a component boundary, and LazyPanel moves to
pages/tripPlanner/LazyPanel.tsx. The page imports both. Comments travel
with their code; two of them lose a dash. Nothing renders or loads
differently.
2026-10-08 05:02:47 +00:00
Maurice 5189d7fcca ci(client): source files may not grow past their size baseline
lint:size counts the lines of every source file under client/src, tests
aside. A file may hold 1000 lines; the 24 files that are longer today are
listed in scripts/size-baseline.json with their current length and fail
the check when they grow. --update lowers an entry once a file shrinks
and never raises one, so a long file is split by concern instead of
growing. A baseline that cannot be read or parsed stops the check with
its reason, and --update leaves it as it is. It runs in the client job
next to the reading direction check.
2026-10-08 05:02:47 +00:00
Maurice bee500880e fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a
count string's form by the language's own Intl.PluralRules, and every
locale now spells out the forms its language reaches with whole
numbers, about 160 count strings in all: one, few and many in Russian,
Ukrainian and Polish, one and few in Czech and Slovak, five forms in
Arabic, one in the others, none in Japanese, Korean, Chinese, Thai,
Vietnamese and Indonesian. Each language was translated and then
reviewed on its own. English no longer reads "1 files", "1 updates
available" or "photo(s)".

Count labels shown next to a number now take that number too: the
atlas and admin statistics, the trip cards, journey and shared trip
pills, packing templates, receipts, storage usage, the Studio export
note. A heading that shared its key with a counted label keeps a key of
its own (the boarding pass heading, the Bookings tab), so languages that
put a noun after a number in the singular keep a plural heading. The
plugin hosts chip passed its count through .replace() after t() and
never got a form; three toasts sent the count as text. Mail and push
texts for shared photos and suppressed storage failures pick their form
in code through pluralForm.

A whole number sent as text counts too, which is how in-app
notification params arrive, and the plural rule follows the language of
the strings on screen while another one still loads.

The parity check knows which forms each language needs and refuses
missing and impossible ones. It also reads keys that contain a colon
now: it skipped them, which hid 58 missing plugin permission texts in
Catalan and 35 in Vietnamese, both translated. The client parity test
covers every language of the registry instead of a hand-kept list of 19,
and the placeholder spec lets a form spell its number out only where
its category is that one number. Brazilian Portuguese error screens
were in Breton, and an Arabic dashboard label held Hebrew text.
2026-10-08 00:01:59 +00:00
Maurice 55697b75c1 test(db): the legacy step map knows the tour type migration
Migration20200101042300_every_tour_type_the_contract_names runs after
the legacy steps like the other post-legacy migrations, but was missing
from the test's list of migrations without a step, so LEGACYMAP-001
failed.
2026-10-08 00:01:43 +00:00
Maurice 960520c6d9 test(e2e): the iPad case checks what broke #1432, the guides read today's texts
The iPad case could no longer fail on its own regression: a draggable
row behind the long-press bridge is what tablets get by design since
#1616, and nothing in the case swiped. What turned swipes into drags in
#1432 was the drag-drop-touch polyfill, so the case now asserts that a
tablet never loads it.

Two help-media guides waited for "1 files" and "2 flight(s) imported",
which now read "1 file" and "2 flights imported".
2026-10-08 00:01:43 +00:00
Maurice d4eccb39a3 fix(client): three more places follow the reading direction in Arabic
On a phone in Arabic every system notice, the release notes included,
opened as an empty sheet: its three-slot strip is shifted to show the
middle slot, and in a right-to-left row that shift moved all three out
of view. The shifts point the other way in a right-to-left page now,
which the swipe handling already assumed.

The budget's total amount kept its parts in order but sat on the
opposite edge from its label, because the whole row was made left to
right. Only the amount itself is now. The phone chat's bubble tails
were still physical corners while the desktop ones follow the row; both
use logical corners now.

lint:rtl takes comments from the TypeScript parser instead of scanning
lines, so a "/*" inside a string (accept="image/*") no longer hides the
lines after it.
2026-10-08 00:01:43 +00:00
Maurice 0126ef3401 fix(client): the version notice asks about a change during the session only
Three gaps in the notice from the previous commit, found in review.

A page served from the network and claimed later by its own build's
worker counted as already taken over, so Reload went to that worker,
got the old build again and spent the session's reload: no notice and
no new build until the next start. Only a worker that replaced the one
that served the page skips the handover now, as at launch.

A reload that came before the new worker could take over (a slow
precache, a failed update, an install gone redundant) also spent that
guard, so the launch behind it could no longer finish the handover with
its own reload. That reload leaves the guard alone now.

An install whose server never reports the bundle's version (a hand-set
APP_VERSION) showed the notice in every session, and Reload could not
clear it. The store keeps the first version a page hears of, from the
app config at launch, and the notice only asks when a later report
differs: a different build at launch is the launch handover's to fix.

The server's wire-envelope test pins the welcome frame's fields and now
expects the version beside the socket id. The wiki's update page says
what an open tab shows after an update.
2026-10-08 00:01:33 +00:00
Maurice 254a214b7d fix(shared): the tour metrics pass the strict shared typecheck
computeTourMetrics indexed points[i - 1] and points[i], which the shared
package's noUncheckedIndexedAccess reads as possibly undefined, so the
shared typecheck in CI failed since the metrics moved there. It walks the
points pairwise from the first one now; the numbers are the same.
2026-10-07 23:09:42 +00:00
Maurice 8dca372c47 ci: run the browser flows and the map bundle check on every change
Both only ran on a developer's machine, and both had stopped telling the
truth. The end-to-end flows now run in CI in Chromium and in WebKit (the
iPad and iPhone cases need the engine every iOS browser uses), with the
browsers cached on the lockfile and the traces uploaded when a flow
fails. A second job builds the client and fails when one chunk carries
both map engines.

Making them pass meant fixing what had rotted. The setup closed every
first-run notice but the release notes, whose X is called Close, so the
notes covered the dashboard and every flow after login timed out on a
click. The iPad case still expected undraggable place rows; tablets drag
on purpose since #1616, behind the long-press bridge, so it now checks
what #1432 is about, a swipe that scrolls and raises no drop overlay,
with locators for the scroller and the Plan tab that match today's
markup. The iPhone case read the body scroll lock through
window.scrollBy, which moves an overflow: hidden viewport in every
engine; it now reads the lock where it takes effect and checks the page
keeps its place.
2026-10-07 22:49:36 +00:00
Maurice a094f79efa feat(client): offer the new version when a release lands during a session
A tab kept open across a deploy ran the previous build until its next
start. The server now names its version in the realtime welcome frame,
which every open client reads again when it reconnects after the
restart a deploy brings. When that version is not the build the page
runs, a notice offers to reload. The reload goes through the same
service worker handover a launch uses: it waits for the new worker to
take over, or for a short timeout, and records the version only when
the takeover happened.

The notice reads the error boundary's texts for the same situation, so
every language has it already. It is one component for both shells,
sits above the offline pill, stays away offline and after a reload for
that version that did not bring it, and can be closed for the session.
2026-10-07 22:40:12 +00:00
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00
Maurice de128cc4ae wip(i18n): plural forms through Intl.PluralRules
Unfinished, continued in the next session: t() resolves key.one/.few/...
by the language's own plural rule, the parity check knows which forms a
language needs, 16 singular/plural key pairs are merged in every locale
and every count === 1 branch in the client uses the plural form. Still
open: the forms each locale needs for the remaining count strings, the
two new labels in every locale, and 35 tests that expect the old English
wording.
2026-10-07 22:57:51 +02:00
Maurice 588e483205 fix(calendar): VTIMEZONE spells out the offset changes of its span
Each zone's VTIMEZONE carried one fixed offset, so a client that reads it
literally showed every event after a change to or from summer time an hour
off. It now lists every offset change in the calendar years its events
fall in, each as its own observance after the offset in force before the
first one. A zone without changes, or a span with no readable date, keeps
the single fixed offset.
2026-10-07 22:56:35 +02:00
Maurice ab343b0a7b fix(client): a phone turned sideways stays in the phone layout
The phone shell was chosen by width alone, and most phones are wider than
767px in landscape, so turning one swapped the whole route tree for the
desktop planner and an open sheet, its input and the map position were
lost. The phone query now also covers a touch screen under 500px tall,
which no tablet is. It lives in one place: the shell, the planner and todo
hooks, the warnings banner, dialog focus and the stylesheets read it, and
six components that measured the window once on mount now follow it.
2026-10-07 22:06:40 +02:00
Maurice e5ceba00f3 fix(tours): wait for the public Valhalla's turn before every request
The public instance allows about one request a second per client, and the
Tours elevation questions went out right behind the route request with no
pause, so they could be refused with a 429 and leave the tour without the
elevation it needs to be saved. Every request to that host now waits for
its turn, across callers; an operator's own Valhalla is not paced.
2026-10-07 21:39:57 +02:00
Maurice e0efa0314a feat(tours): tours read and write through the offline core
A tourRepo replaces the direct toursApi calls: reads fall back to a new
Dexie tours table, and a tour saved or edited offline is written there and
queued like a place, under one temporary id for the place and its facet.
The queue moves both to the real id on replay and drops both when the
server refuses the create. Trip sync caches each trip's tours while the
addon is on, so a tour no longer reads as a plain place offline.

The tour metrics move to shared so the offline copy shows the server's
numbers, and useTourPlaceIds keeps one Set per change as intended.
2026-10-07 21:30:45 +02:00
Maurice f7edd849ab fix(tours): one tour type catalogue, and no column nothing writes
tour_types now holds every key the shared contract accepts, all but hike
disabled, and create and edit turn a disabled type away with a 400 instead
of failing on the foreign key. tours.tour_group_id is dropped: nothing ever
wrote or read it. difficulty and wanderer_ref stay for the Wanderer import.
2026-10-07 20:54:10 +02:00
Maurice f64f9e58de fix(client): give the context menu a menu role, wrap the tour filter callback 2026-10-07 20:35:15 +02:00
Maurice bd8e1a287b test(oauth): the module now provides the token retention job 2026-10-07 17:55:25 +02:00
Maurice e6e270610c fix(places): type the tour mark on the single-place row 2026-10-07 17:37:26 +02:00
Maurice 57df945318 style: plain punctuation in the new comments and test names 2026-10-07 17:12:26 +02:00
Maurice 40bce5554d docs: the key check, the restore guard, the new probes, failed offline changes and reminders
Updates the wiki for the changes in this branch: TREK refuses an
ENCRYPTION_KEY that disagrees with the key file, the rotation script
covers the AI Parsing key and moves the file along, a restore refuses a
newer backup and keeps the replaced database, the chart's startup and
readiness probes, PORT and extraEnv, retrying or discarding failed
offline changes, trip reminders catching up, and why a Portainer
recreate used to keep reporting the old version.
2026-10-07 17:11:56 +02:00
Maurice 6b037e0fa0 refactor(tours): measure distances with the shared haversine
The Tours metrics carried their own copy of the great-circle formula
next to nest/common/geo.ts, which exists so there is one.
2026-10-07 17:10:59 +02:00
Maurice e4ed4bfc7b fix(mobile): the booking, transport and place sheets ask before dropping what was typed
On the phone a flick of the sheet's top edge, a tap on the scrim or
Escape closed the booking, transport and place forms and threw away
whatever had been typed, without a word; the desktop dialogs have asked
since #2253. The question moves into a shared hook that DialogShell and
MSheet both use, and the three sheets pass their form state to it.
2026-10-07 17:10:14 +02:00
Maurice c3c21fb0b4 fix(client): five loads that catch their own error recover from a deploy too
The PDF export on desktop and phone, the language switch, the holiday
regions and the PDF's map image load a chunk on demand and catch the
failure themselves. After a deploy the old page asks for chunk names the
new build no longer has, so they showed their own error on every retry
(or, for the language, silently stayed in the old one) instead of
reloading onto the new build the way an uncaught chunk error does.
importChunk reloads once for a missing chunk and still hands the caller
its error.
2026-10-07 17:05:36 +02:00
Maurice 0a1a5092f7 fix(ui): the context menu answers the keyboard and closes when the page moves
The shared context menu closed only on a click elsewhere: Escape did
nothing, the arrow keys did nothing, and a scroll left the fixed menu
hanging over content that had moved on. It now takes focus on its first
item, moves with the arrow keys, and closes on Escape, scroll and
resize. rules-of-hooks is an error again; its one hit was a test helper
named like a hook.
2026-10-07 17:03:47 +02:00
Maurice 094a46a3cf fix(vacay): stop swallowing a failed delete as a holiday API error
One try around the holiday fetch and the deletes that follow it caught
both, so a database error while removing vacation days that fall on a
public holiday was logged as nothing and left the days standing. Only
the fetch is tolerated now. Also drops two files long gone from the
Sonar coverage exclusions.
2026-10-07 16:59:52 +02:00
Maurice 48f9e7d132 perf(db): index every foreign-key column
86 foreign-key columns had no index. SQLite does not add one by itself,
so every cascading delete of a trip or a user scanned each such child
table in full, inside the transaction, while every other request waited
on the one connection. One migration adds them all, and the entities are
regenerated to match.
2026-10-07 16:56:58 +02:00
Maurice 1a4df59bf3 ci: give every job a timeout
No job had timeout-minutes, so a hung step held a runner for GitHub's
six-hour default. Test and housekeeping jobs stop after 45 minutes, the
image builds after two hours.
2026-10-07 16:52:26 +02:00
Maurice c60277c4d2 fix(tours): a tour another member creates is a tour on the phone too
The list read marked tour places with tour_place_id, but the single-place
read behind place:created and place:updated did not, and the phone's
places browser kept a second tour list of its own that no realtime event
updated. A tour another member made, or one imported from GPX, showed as
an ordinary place on the phone until the screen remounted, and every
tour did offline. The single read carries the mark now, the browser asks
the planner, and the tour id set is memoised instead of rebuilt on every
render.
2026-10-07 16:52:05 +02:00
Maurice 72fbbd55d4 fix: a trashed note file leaves its note, quieter MCP sweep, bounded compose logs
A note's attachment moved to the trash from the file manager kept
showing on the note; the hydration read now skips it, as the chat one
already did. The MCP session sweep logged a line every minute while any
session was open; it logs only when it cleaned something. The compose
file caps the container log, and three test mocks that pointed at
modules long gone are removed.
2026-10-07 16:47:53 +02:00
Maurice 76df44d8de fix(oauth): purge tokens whose refresh token expired a month ago
Every refresh writes a new oauth_tokens row and nothing ever deleted the
old ones, so an MCP client refreshing hourly grew the table, and every
backup with it, without end. A nightly job now deletes tokens whose
refresh token expired more than 30 days ago, leaves first, and keeps any
row a live chain still points at for replay detection. Also types the
backup's per-entry store flag.
2026-10-07 16:45:21 +02:00
Maurice a869269591 fix(deploy): follow PORT in every probe, wait for a slow boot, and probe the database
Changing PORT left the Dockerfile and compose healthchecks, the chart's
containerPort, Service target and probes on 3000, so the container went
unhealthy and Kubernetes restarted it in a loop. All of them follow PORT
now. The chart gains a startup probe (ten minutes for a first boot, a
long migration or a restore at boot, which liveness used to kill after
about 105 s), a readiness probe on the new /api/health/ready that asks
the database, extraEnv and extraEnvFrom for the settings the chart does
not list, slots for the four secrets it had none for, and the
DEFAULT_LANGUAGE and MCP_MAX_SESSION_PER_USER values it documented but
never set. The Dockerfile no longer hides a failed npm ci behind a ';',
pins gosu, and the language lists name all 27 locales.
2026-10-07 16:40:40 +02:00
Maurice 8c3fad5e82 fix(auth): reset-admin.js ends the account's sessions and matches its email in any case
Resetting a locked-out or compromised admin left every session on that
account valid, because the script did not move password_version on the
way every password change in the app does. It also looked the address
up case-sensitively and created a second account when the stored one
had capitals.
2026-10-07 16:36:39 +02:00
Maurice 8923918eb4 fix(i18n): send Catalan and Vietnamese notification mails in their language
Both locales had their notification texts, but the index never imported
them, so their users got English mails and the parity gate passed
because the files existed. The locale map is now typed against the
supported languages, so the next one left out fails to compile.
2026-10-07 16:36:01 +02:00
Maurice 8f02337d45 fix(i18n): hand Intl and the document the real language tag for br and gr
TREK's codes 'br' (Brazilian Portuguese) and 'gr' (Greek) went straight
into <html lang> and into ten Intl calls. 'br' is Breton, so Brazilian
users got Breton month names and screen readers announced the page as
Breton; 'gr' is no language at all, so Greek users got the default
locale. getIntlLanguage maps both, and every one of those sites goes
through it.
2026-10-07 16:34:59 +02:00
Maurice 461a63fc86 fix(auth): send the queued changes before a logout deletes the offline database
Logging out deleted this user's offline database straight away, so a
change made offline and not yet sent was lost without a word. With a
connection, the logout now flushes the queue first, for at most five
seconds.
2026-10-07 16:32:57 +02:00
Maurice 38656eeffd fix(offline): let the cache drop what was deleted elsewhere
The trip sync and every repo list only ever upserted, so a place a
collaborator deleted while this device was offline came back on the
next offline read, and a trip the user had been removed from stayed on
the device. A sync and a full list now replace the trip's rows, keeping
only the ones created offline that have not synced yet, and a cached
trip the server no longer lists is cleared.
2026-10-07 16:32:02 +02:00
Maurice f092e7a95f fix(offline): a write the server keeps failing on no longer blocks every other write
A queued change the server answered with a 5xx was put back at the head
of the line and the whole flush stopped, every 30 seconds, forever: one
bad payload held every later change of every trip. Now only its own
trip waits, with gaps doubling from 30 s, and after eight failures it is
parked as failed. A DELETE answered 404 counts as done instead of
showing as a failed sync. Settings > Offline offers to try parked
changes again or discard them.
2026-10-07 16:30:07 +02:00
Maurice 3f9b751ee3 fix(realtime): open no socket after a logout, and reconnect as soon as the network is back
A connect still waiting for its ws token when the user logged out went on
to open a socket authenticated as that user, which the next login on the
same browser then reused. And a socket in its reconnect backoff waited up
to 30 s after the network came back or the tab became visible. Both
events now reconnect right away.
2026-10-07 16:25:00 +02:00
Maurice f02253a44c fix(planner): drop search and day-panel answers that arrive too late
The desktop day panel stays mounted across a day switch, so a forecast
or the stays still loading for the previous day overwrote the new day's
once they arrived. The location search in the transport form showed
results for text the user had already changed, or refilled the list
after a pick. Both now drop an answer for a request that no longer
matches.
2026-10-07 16:23:47 +02:00
Maurice 205759e3cf fix(scheduling): do not start a cron tick while the last one still runs
CronJob ran a tick on schedule even when the previous one had not
finished, so an hourly auto-backup of a large library could overlap
itself, and a rejected tick only reached cron's own console line. Jobs
now wait for completion and log failures through the app logger. The
backup also stores photos, videos and PDFs instead of deflating
already-compressed files at level 9.
2026-10-07 16:21:16 +02:00
Maurice 7e8c9ac3a5 fix(server): give the last six outbound fetches a timeout, and lint for it
Overpass, the Wikipedia photo lookup (which holds one of five shared
photo slots), the plugin download and registry, and the two API key
checks had no signal, so a provider that took the connection and went
quiet held the request for undici's 300 s default. ESLint now refuses a
fetch() whose init carries no signal.
2026-10-07 16:19:55 +02:00
Maurice a8fc93dfde fix(notifications): take today from the instance's TZ and catch up missed trip reminders
The reminder crons run at 09:00 in the instance's TZ but computed today
in UTC, so east of Greenwich a trip reminder arrived a day late. A day
the job did not run also lost that day's trip reminders for good: the
match was 'starts exactly N days from today'. Trips now record the start
date a reminder went out for, and the job sends every reminder that is
due and not yet sent. The active-trip redirect and the upcoming
reservations use the instance's date and clock as well.
2026-10-07 16:16:26 +02:00
Maurice e0aedbb2c6 fix(trips): count trip dates in calendar days, the same on REST and MCP
Filling in a missing end date and keeping a trip's length when its start
moved both shifted a local-time Date, so a trip crossing a DST change
lost or gained a day. Both now use addIsoDays. The one-date-makes-a-week
default moves from the REST controller into TripsService.create, so the
MCP create_trip tool no longer stores a half-dated trip, and the trip and
its days are written in one transaction.
2026-10-07 16:11:41 +02:00
Maurice bfa512589c fix(db): baseline a database from the pre-ORM Tours branch correctly
That branch numbered its own steps after the upstream step it was cut
from, so a database at schema_version 243 to 246 (or 216 to 218) with the
Tours tables had four upstream migrations marked as run without ever
running them. The baseline now cuts such a version back to the last
upstream step, and the Tours migration adds the difficulty cap to a tours
table an earlier branch stage created without it.
2026-10-07 16:08:48 +02:00
Maurice 63f7eab4bb fix(auth): match email addresses case-insensitively everywhere
Login and the OIDC fallback match emails case-insensitively, but the
password reset lookup and the admin create and edit checks compared
exactly. An account stored as Foo@x.com never got a reset mail, and an
admin could create bob@x next to Bob@x, after which login picked
whichever row came first. The signup collision check and the first-user
count also move inside the signup transaction.
2026-10-07 16:07:30 +02:00
Maurice e7ab0fc21e fix(users): delete a user who recorded a payment between two other members
budget_settlements.created_by_user_id has no ON DELETE action and the
account cleanup never cleared it, so deleting such a user failed on the
foreign key and rolled the whole deletion back. The cleanup now clears
the recorder; the payment itself belongs to the two members and stays.
2026-10-07 16:05:02 +02:00
Maurice 71a0af02b0 fix(db): refuse a database or backup a newer TREK migrated, and keep a copy before a restore
Rolling an image back, or restoring a backup a newer release made, ran
older code against a schema it does not know and failed later, far from
the cause. Boot now refuses a database that records migrations this
build does not ship, and the restore check refuses such a backup while
the current database is still in place. A restore also keeps the
database it replaces as data/pre-restore-<time>.db.
2026-10-07 16:03:19 +02:00
Maurice 99263fdbeb fix(security): refuse an ENCRYPTION_KEY that disagrees with the key file
An env value different from data/.encryption_key used to overwrite the
file on boot, destroying the only copy of the key the stored secrets were
encrypted with. The server now refuses to start and names the rotation
script, which re-encrypts the AI Parsing addon key as well and moves the
key file to the new key. Under test the suite's fixed key no longer
overwrites the developer's key file.
2026-10-07 15:57:53 +02:00
Maurice c6b6b83d38 fix(docker): take the version from the image, not from the container's env
Portainer's recreate and a copied run config carry the previous image's
APP_VERSION into a container built from the new one, so the server kept
announcing the old release and clients never handed over to the new
bundle. The Dockerfile now writes the version into server/VERSION, which
the server reads before APP_VERSION, and prerelease bundles take their
version from the same build argument.
2026-10-07 15:57:52 +02:00
Maurice c956fe92ba Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:

- one MikroORM migration for tour_types, tours and tour_waypoints,
  idempotent so instances that ran the old positional steps keep
  their data; the hike tour type is seeded inside it, the addon row
  in the AddonSeeder
- entities and repositories for the three tables, tours domain on
  repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
  repository code

Fixes found while porting:

- GPX import checks addon, trip access and permission in the handler
  instead of guards, so a refused upload gets a 403/404 instead of a
  connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction

Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
2026-10-06 22:43:09 +02:00
jubnl cb47947bb7 fix production deps 2026-10-06 19:43:58 +02:00
jubnl de8caa81a2 Feat/orm : mikroorm (#2416)
* base entities

* update deps

* fix date as string

* rename entities

* migrations + seeders

* feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text

* feat(db): discover entities from an explicit list instead of a glob

* test(db): createTestOrm binds an ORM to a suite's own SQLite handle

* feat(db): dialect helper is the one place SQL functions are spelled

* test(db): dialect helper refuses malformed column references and day counts

* feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)

* fix(db): repositories mirror the statement's column set and guard their row types

* feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests

* test(server): integration suites boot on the migrated schema snapshot

* test(server): snapshot harness keeps the legacy id semantics

* fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config

* test(db): coverage ratchet entries for the ORM foundation

* test(server): websocket suites boot on the migrated schema snapshot

* test(server): coverage's uncovered-file pass goes through the SWC transform

* fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer

* test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema

* chore(server): promise safety net and sweep tooling for the async migration

* refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork

* fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test)

* test(doc-sync): unit suites await the methods the async sweep made async

* refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule refusing a Promise used as a plain value, and the misses it found

* test(server): suites await the methods Task 1's forced edits made async

* refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork

* test(oauth): controller suite awaits the async service

* chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await

* refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork

* refactor(plugins): async DB-touching methods; transactions through UnitOfWork

* chore(server): db-call-graph --sync stops its transitive walk at an async callee

* chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub

* refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork

* refactor(auth, files): async DB-touching methods; transactions through UnitOfWork

* test(server): suites await the methods earlier sweep groups made async

* refactor(tokens): async DB-touching methods; the auth token handlers await them

* test(files): upload filters fail closed on an excluded or unresolvable type

* refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork

* refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects

* refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork

* fix(vacay): type the loop accumulators the async sweep introduced

* refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork

* fix(server): the journey hooks are awaited by their callers, not detached

* refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork

* refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias

Recipe R4 trap named in the Task 12 brief: a .bind alias of an
AssignmentsService/AccommodationsService method is typed any and hides a
missing await from tsc and the promise lint rules. createAccommodation
belongs to this task's group, so rewritten as an arrow forwarding to the
method instead of a bound reference.

* refactor(assignments): drop the unused Trip type alias

* refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork

* refactor(roadtrip, reservations): return await on transactional; document the single-flight promises

* refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork

* refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork

* refactor(trips, feeds): drop added return annotations; no double await on the feed guard

* fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports

* fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test

* test(db): entity metadata must match the migrated schema; fix the four drifts it found

* feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)

* test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways

* test(db): numeric ids for the primary-relation spike

* feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules

* fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures

* fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives

* refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity

* fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case

* chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked

* feat(db): AppSettings and Users repositories; forFeature wiring pattern

* refactor(permissions): permission flags through AppSettingsRepository

* fix(server): boot-time settings read runs inside an explicit ORM request context

* refactor(audit): audit log writes through AuditLogRepository

* fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config

* docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes

* fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs

* fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors

* refactor(categories, tags): repositories replace raw SQL

* refactor(addons): addon catalog and photo providers through repositories

* fix(db): generator captures inline UNIQUE constraints

SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_
entry before entity-generator ever sees it, so a table-level
UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX
(settings' UNIQUE(user_id, key) among ~40 others) never reached the
generated entity's uniques: metadata. Adds a general
RULE9_addImplicitUniqueConstraints (skips a column set identical to
the table's own primary key — AppSettings.key/Addons.id noise) fed by
a PRAGMA index_list/index_info scan, and regenerates every entity.

* chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories

* fix(platform): well-known gate tolerates a synchronous addon read from the context helper

* fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes

* refactor(settings): user and instance settings through repositories

SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.

An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).

* test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host

* fix(db): generator names relation properties in captured UNIQUE constraints

* fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions

* fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed

* fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned

* feat(db): generated entities declare their repository type; test casts dropped

* fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed

* feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository

* refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed

* refactor(tokens): MCP and API tokens through McpTokensRepository

* refactor(auth): passkeys and registration invites through repositories

* fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned

* fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned

* refactor(oauth): OAuth server tables through repositories

OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.

* fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned

* fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped

* refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default

* refactor(auth): AuthService and account cleanup through repositories

* refactor(oidc): OIDC identities and settings through repositories

* fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned

* fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing

* refactor(server): trip-access primitives async at every caller

* refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster

* refactor(days): days and day notes through repositories

* refactor(assignments): day assignments through repositories

* refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories

* refactor(trip-members): membership, guests and the trip read model through repositories

* refactor(places): places, tags, ratings and place images through repositories

* fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned

* refactor(places): import paths through repositories

* refactor(trips): trips through repositories

* fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed

* refactor(trips): trip copy through repositories for the owned tables

* fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate

Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.

PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.

DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.

Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.

Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.

* test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB

* fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets

* feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets

* refactor(roadtrip): roadtrip tables through repositories

* refactor(reservations): reservations, endpoints and travelers through repositories

* refactor(feeds): ICS feed tokens and anonymous feeds through repositories

* refactor(accommodations): stays through repositories inside the existing transactions

* test(server): plan 3d — MCP test helper constructors follow the accommodations and days services

* refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables

* refactor(calendar): calendar reads and reservation visibility through one predicate

* feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit

* fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories

* refactor(photos): trek photos, memories access and cache sweeps through repositories

* refactor(files): trip files and file links through repositories, with transactions added

* test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors

* refactor(memories): unified, immich and synology photo sync through repositories

* refactor(collab): notes, polls, messages and links through repositories

* refactor(budget): budget items, members, payers and settlements through repositories

* refactor(todo): todo items through repositories

* refactor(packing): packing items, bags and templates through repositories

* feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins

* refactor(school-holidays): country and region catalog through repositories

* refactor(notifications): in-app notifications and channel preferences through repositories

* refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories

* fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories

* refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories

* refactor(system-notices): notice dismissals through a Nest provider and repository

* refactor(atlas): visited countries, regions and bucket list through repositories

* feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations

* refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories

* feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep

* fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories

* refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories

* refactor(journey): public share links, TREK Studio books and the journal facades through repositories

* refactor(collections): access model, hydration, export/import and list CRUD through repositories

* refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories

* refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories

* refactor(collections): saved places, copy-to-trip, labels and invitations through repositories

* refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories

* refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories

* test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum)

* fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories

* feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations

* feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness

* refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap

* refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories

* feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision

* refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories

* test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4)

* refactor(plugins): the trip-write role gate through a repository, in isolation

* refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService

* fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations

* refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories

* fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository

* refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories

* refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories

* refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories

* test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot

* test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot

* fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories

* refactor(server): plan 4 — convert the program's orphaned raw-SQL callers

* fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)

* fix(server): plan 4 — RULE12 paren-count scanner ignores string literals

The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression
paren-balance scanner counted every `(`/`)` character regardless of
whether it fell inside a single-quoted SQL string literal. A CHECK
expression like `status IN ('confirmed)', 'tentative')` has a literal `)`
inside its first string value; the scanner would hit depth 0 at that
in-string character and truncate the expression, losing its own real
closing paren. The scanner now tracks single-quote string state (handling
the doubled `''` SQLite escape) and skips parens while inside one.

Latent-bug fix — no shipped entity's CHECK expression has hit this shape
yet (3d's ledger: "nothing affected today").

* refactor(server): plan 4 — unify the trip-access predicate's Kysely copies

TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the
identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL)
through the Kysely expression builder. Task 7's security review (M1)
evaluated a shared helper and set it aside for time, falling back to the
TRIPREPO-041 cross-method parity test as a regression guard instead.

Extracted the boolean into one generic helper, tripAccessExpr
(_shared/trip-access.ts), following the reservation-visibility.ts
publicReservationExpr precedent: fully generic over <DB, TB> since the
predicate has no correlated subquery, so it composes across
TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower
fixed-alias shape. The LEFT JOIN itself stays hand-written at each call
site — tripSelectQuery's own docstring already explains why a shared join
helper can't be typed across these differently-joined outer queries.

feeds.service.ts and memories-access.service.ts no longer duplicate this
predicate at all (Task 2's facade-inline sweep already converted both onto
TripsRepository.findAccessible/getFeedTokenIfReachable/
listReachableActiveTrips, which read through the canonical MikroORM
QueryBuilder form, accessibleTripsQuery) — the remaining duplication this
task closes is the three Kysely copies inside this file.

TRIPREPO-041 (64/64 in the file) stays green as the unification's own
regression guard.

* fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL

The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly
make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer
(TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path)
has always bound a real string — 3e's R5 confirmed this. Appends a new
migration rebuilding the table with cache_key NOT NULL (SQLite has no
ALTER COLUMN, so this follows the same rebuild recipe
Migration20200101020000 uses in the opposite direction) and updates the
entity/generated metadata to match. Schema tightening, not a behaviour
change.

* refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23)

Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/
listByTripAndIds and Trips.repository.ts's findPublicForShare all took a
raw `.where('col = ?', [v])` string condition even though their id/trip_id
parameters were already plain `number` (or, for findPublicForShare,
narrowed to `number` here after confirming its one caller,
share.service.ts#getSharedTripData, only ever passes a number sourced
from a Kysely-typed share_tokens row, never an unconverted URL param) —
converted all six to MikroORM's typed `.where({...})` filter object.

The remaining raw string-form conditions in src/db/repositories/** are
NOT converted here: they are the `number | string` raw-bind seam
findAccessible/isOwner's own docstrings describe (trip_id/id threaded
through unconverted from req.params), which structurally requires the
deferred guard-level single toRowId parse to land first — narrowing any
of those methods' signatures now would either break the '0x10'/'007' id
parity those docstrings warn about, or duplicate the deferred item's own
work ahead of it landing.

* refactor(server): plan 4 — trek-photos.repository.ts finishes its rename

3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService,
since the name collided with the generated ORM TrekPhotos.repository.ts) but
left the FILE and its test file's names unchanged. Renames both to
trek-photo-registration.service.ts / .test.ts.

The old path is kept as a thin re-export stub (the src/websocket.ts shape)
rather than repointed at every one of its ~20 importers in this pass: several
of those files sit in two sibling tasks' exclusively-owned windows on this
shared branch (Task 3's budget/packing/todo/accommodations/places ripple,
reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts;
Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts)
and are off limits here. Flagged for a follow-up once those tasks land: delete
the stub and repoint every remaining importer at ./trek-photo-registration.service.

* fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic

install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.

Deliberate behaviour change, named for the user per the task brief.

DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.

* refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags)

Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds
directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and
DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings
in accommodations.service.ts (placesRepo was already injected there).

BudgetService/BudgetMcp/TodoService collapse their constructors, dropping
the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts
register TripMembers on forFeature). PackingService keeps DatabaseService as
a vestigial param behind an optional TripMembersRepository with a fallback
in tripRosterIds: an in-flight Task 5c file
(tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService
positionally without the new repository, so its arity cannot change this
task; the fallback keeps that file's DatabaseService.rosterUserIds spy
working until Task 4 drops the param with the class itself.

* test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline

Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).

* fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too

Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow):
PluginRuntimeService's own discoveryRepos getter still built the bundle
without a uow field. Threads its already-injected, @Optional() uow through
so its own onApplicationBootstrap/reload/dev-link discovery calls also get
the transactional DI5-DI8 refresh, not just PluginRegistryService's.

No throwing guard here, unlike PluginRegistryService's: several hand-built
partial-DI-graph test instances of this class (boot-registry-order.test.ts,
in a sibling task's held file window) construct it with uow omitted and
exercise onApplicationBootstrap's discovery call directly. Discovery must
never refuse outright just because a test built a partial graph, so it
degrades to the pre-existing untransacted sequence when uow is absent
(DiscoveryRepos.uow's own docstring covers this), the same "boot must
never block app init" defensiveness this file already applies elsewhere.

* fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2)

TripAccessGuard/TripOwnerGuard already parse :tripId exactly once
(Number(), guarded by Number.isFinite) and hand the resolved TripAccess
row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing
TripAccessGuard's own lookup when both are on the same route rather than
re-querying — verified by reading both guards directly, no change needed
there.

PlacesService.verifyTripAccess's own docstring separately claimed its
returned `tid` gets "returned... for every downstream call to reuse
(never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading
every call site (not assumed) shows this is false: `.tid` has exactly one
reader (PlacesController.requireTrip itself), which discards it — none of
requireTrip's 13 callers read `.tid` off the trip it returns; each passes
the original raw tripId string on to the write/read method it calls next,
which toRowId-parses it again itself. That second parse cannot be
eliminated by this gate alone: the same service methods are called
directly by places.mcp.ts too, an entry point with no requireTrip gate at
all (its own numeric tripId is re-stringified, String(tripId), to call
them), so each method owns its own id validation regardless of entry
point. Threading `tid` all the way through would mean giving every one of
~15 methods a second, number-only call shape for the REST path alone to
use — a real refactor, out of this docstring fix's own scope. Corrected
the docstring to say so plainly instead of repeating the false claim.

* refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub

JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).

* refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates

3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after
unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as
"same class as OauthClients:147" but left them unexamined per-site. Decided
per site: document, don't delete — TypeScript still types
executeTakeFirst()'s result as `| undefined` even though SQL guarantees
exactly one row for these unqualified aggregates, so the optional chain
can't be removed without a non-null assertion (worse, not safer). Six
sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42,
countStatsPlaces JG67), JourneyEntryPhotos.repository.ts
(maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder
JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction
VC2). No behavior change — comments only.

* refactor(server): plan 4 — unify the two user-data-erasure enqueue paths

UserCleanupService.erasePluginUserData (the account-deletion transaction's
own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the
emitUserDeleted sink's post-commit enqueue) independently walked the
identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data
filter + INSERT OR IGNORE — and both genuinely run for every account
deletion, since every emitUserDeleted call site (auth.service.ts,
admin.service.ts, trip-members.service.ts's guest removal) fires
immediately after deleteUserCompletely.

Extracted the shared filter into enqueueHookUserDataErasures
(nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it.
UC2/UC3 also come off DatabaseService onto PluginsRepository/
PluginUserErasureQueueRepository in the same change (they were the last
raw statements standing in the way of sharing one implementation) — this
narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1
only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which
still stays raw, unaffected. UserCleanupService's orphan-data-directory
scan (a plugin uninstalled with data retained) has no equivalent on the
enqueueUserErasure side and stays there, now via the same repository.

Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this
idempotent) — this unifies the IMPLEMENTATION, not the call count; not
touching the emitUserDeleted event-sink wiring itself was a deliberate,
smaller-blast-radius choice for this task.

* refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number

Both accepted trip_id: number | string per 3c's carry note (their "stays
loose for other callers" reasoning), pending the guard-level single parse
landing first. Re-checked every caller directly: setOwner's one production
caller (trip-members.service.ts#transferOwnership) already passes trip.id,
a real number resolved earlier in the same transaction; remove's callers
are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod-
typed tool input, already a number) and TripMembersController.removeMember
(the one genuine raw-string caller, :id off the route with no class-level
TripAccessGuard on this controller by design).

TripMembersController.removeMember now parses :id once (toRowId, not
Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to
both its own access check and removeMember, instead of passing the raw
string through twice. TripMembersService.removeMember and both repository
methods narrow to number accordingly; setOwner's WHERE also moves off its
raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and
remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter
(user_id keeps its own raw bind — a different, still-live NaN-safety seam,
unrelated to this narrowing).

TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam
on setOwner/remove, no longer typecheck against the narrowed signatures
and are removed with an explanatory comment in their place.

* refactor(server): plan 4 — U6 gate-level id parsing for todo item ids

Rule 21's carry from 3e Task 8's review: TodoController.update/.remove
passed the raw :id route string all the way to TodoItemsRepository,
relying on SQLite's column-affinity CAST to match it instead of a typed
filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's
TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread
the number down through TodoService.updateItem/deleteItem into
TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all
narrowed from number | string to number. todo.rpc.ts's num()-derived
todoId no longer needs its String() wrapper. tripId stays string | number
— a separate, already-accepted carry (23c292f2f). Two new e2e tests pin
the non-numeric-id 404 at the gate.

* test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4)

3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests.
17 cases pinning every read against the legacy statement run raw on the
same connection — findRawByTrip/findTokenByTrip with every nullable
column both null and set, findValidByToken/findTripAndShareMapByToken/
findTripIdByToken's shared token+expiry predicate (valid, future-expiry,
past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/
deleteByCreator's write correctness (deleteByCreator's cross-trip erasure
kept distinct from deleteByTrip's single-trip scope, per the repository's
own docstring). The listPublicForShare family (the other half of 3h L4)
is a separate, larger carry — not in this file.

* test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry)

Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts
reads on ReservationsRepository flagged by the 3d Task 7 review as having
no repository-level toEqual(<legacy raw>) coverage (listEndpointsForShare,
listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare,
listScheduledForPublicApi, listUnscheduledForPublicApi,
listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new
cases against the legacy statement run raw on the same connection.

* test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)

Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.

* test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)

Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.

* test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks

235f03665 landed two versions of the same DaysRepository.listPlanDays and
PlacesRepository.isTrackInTrip parity coverage side by side (a merge
artifact from concurrent work on the same carry item). Keeps one block per
method, drops the duplicate describe/it blocks and their colliding test ids.

* test(server): plan 4 — budget calc suite onto real repositories (3e carry)

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)

Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry)

Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key
has no at-rest pin", "document_connections.secrets ... has no enc:v1: check",
"trip_document_links.webhook_secret - no at-rest pin"). Three targets, each
proven by reading the raw column and asserting the stored value is
enc:v1:-prefixed ciphertext that round-trips, never the plaintext:
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the
repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the
service layer, and DocSyncConfigService's connection secrets map plus
createLink's webhook secret.

* test(server): plan 4 — drop duplicate AirTrail encryption repository test

Task 8b-2 item 4 follow-up: 6965869dd already covers
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest
ciphertext at the service layer (airtrail.service.test.ts's
AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins
(DOCSYNC-ENC-001/002); this repository-level file duplicated the same
AirTrail assertion a second time.

* test(server): plan 4 — atlas read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (atlas half): full-key toEqual(<legacy raw>) coverage for
the atlas read models the 3f Task 7 review's L6 finding listed as missing
(minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places.
listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/
listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/
listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/
listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser),
AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/
countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34
(BucketList.listForUser/findById/findForUser), AT41 (Places.
listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone
files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which
had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids
only by the existing consumer test — full-row parity for those three is
NOT done here (carried forward, same as the review's own open item).

* test(server): plan 4 — vacay read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (vacay half): full-key toEqual(<legacy raw>) coverage for
the vacay read models the 3f Task 7 review's L6 finding listed as missing
(minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the
getPlanData/getStats composite's own plan read), VacayYears.listForPlan
(listYears), VacayCompanyHolidays.listForPlan (the company-holidays list),
VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads),
VacayUserSettings.findForUser (user year settings), VacayUserYears.
findForYear (getStats rows, including the NULL vacation_days case the
review named explicitly), and VacayPlanMembers.listAvailableForFusion/
listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven
new standalone files — only VacayShares had a repository-level test before
this. getSharedCalendars itself is a service-level composite over these
same reads plus VacayCompanyHolidays.listDatesForRange (already exercised
elsewhere); its own dedicated test is NOT added here.

* test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH13 read. Seeds Common items with every nullable
column both null and set, plus Personal and Shared-with-people items on
both sides of the is_private/owner/recipient predicate, and proves only
the Common tier surfaces.

* test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH14 read. Seeds two items — every nullable column
set and every nullable column null — plus a foreign-trip item, and proves
the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has
none).

* test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH16 read. Seeds a root message, a reply (reply_to
set), a deleted message (must be excluded) and a foreign-trip message, and
proves the narrower username/avatar-only join shape (no reply_text/
reply_username, unlike joinedQuery).

* test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH12 read. Seeds a categorised place with every
nullable column set and an uncategorised place with every nullable column
null, plus a foreign-trip place, and proves the named 20-column allow-list
never leaks an owner-only column (reservation_status, google_place_id,
…).

* test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the SH9 read. Seeds assignments across multiple days (one excluded day_id
never leaks in), every nullable place/assignment column both null and
set, and proves the six owner-only columns SharePublicAssignmentRow omits
(google_place_id, google_ftid, osm_id, amap_poi_id, stop_type,
fill_percent) never appear on the row at all.

* test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests

* test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up)

* fix(server): add the missing numbered migration for the booked-night reseat step (#242)

reseatBookedNights (legacy step 242) was never invoked from any shipped
MikroORM migration -- only from the retired db/migrations.ts array --
so an install upgrading through the numbered chain never got the
one-time reorder. Adds Migration20200101040300, which calls the frozen
function through the native better-sqlite3 handle so it runs inside
the migration's own transaction.

orm-driver.ts's BoundSqliteConnection now also captures that handle
onto the field SqliteConnection.getNativeClient() reads, which its
createKyselyDialect() override previously left unset -- needed for
this migration (and any future one) to reach it in production, not
just under the bare test driver.

RESEAT-009 moves off the legacy createTables+runMigrations builder
onto the real Migrator via tests/helpers/migration-step.ts, plus a
new fresh-install guard (RESEAT-011).

* test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder

day-notes.service.test.ts, public-api-scopes.test.ts,
registration-invites.service.test.ts, trip-invite.service.test.ts onto
createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent),
dropping their own hand-rolled createTables()+runMigrations() harness.
trip-invite.service.test.ts and registration-invites.service.test.ts
also drop their now-redundant DatabaseService wrapper (its .connection
was always just the same test db handle). expect( assertions unchanged.

user-cleanup.service.test.ts held back: its constructor call was
updated to match UserCleanupService's new EntityManager-based shape,
but deleteUserCompletely() now deadlocks against a real UnitOfWork
(reproduced in isolation, independent of this test file -- see the
report), which looks like a genuine bug in erasePluginUserData's new
MaintenanceRepository(em) call versus UnitOfWork.transactional(), not
a test-harness problem.

* test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb()

Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in
auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for
their own archiver/filesystem assertions, which broke
createSnapshotTestDb()'s read of the schema snapshot through
schema-snapshot.ts's own fs import.

auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now
built via a small helper that points fsMock's three calls at the real
filesystem (fetched through vi.importActual, bypassing the mock) just
long enough to build the db, before the test installs its own
existsSync stub -- never touching schema-snapshot.ts itself.

demo-reset-path.test.ts: fs.existsSync is selectively spied to answer
only for the baseline path, so the db is now built before that spy is
installed instead of after.

* test(server): plan 4 — retire the legacy-runner-only migration hygiene checks

migration-hygiene.test.ts deleted outright: every block in it (the
destructive-op allowlist, the empty-catch guard, the full-chain smoke)
scans or runs the legacy db/migrations.ts array being deleted this
plan, never the numbered src/db/migrations/*.ts chain, so there is
nothing in it to carry forward.

leg-mode-incoming.test.ts: deleted its first describe block (the
legacy-runner column-add + replay-safety smoke, also against
db/migrations.ts); the read-path parity block, already on the modern
createSnapshotTestDb() harness, is untouched.

* chore(server): delete the legacy schema/migration/seed builders

MikroORM's migrations are the only schema source now, including for
tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are
gone, along with tests/unit/db/schema-parity.test.ts (the parity gate
that guarded them, already red on 8a's cache_key NOT NULL) and
createTestDb() + its now-dead imports out of tests/helpers/test-db.ts
(its last two callers moved onto createSnapshotTestDb() this plan).
Their three entries drop from eslint.config.mjs's better-sqlite3
allow-list -- the files they exempted no longer exist.

Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts,
document-provider-seed.ts) and every numbered migration in
src/db/migrations/ are untouched.

Precondition grep (server/tests + server/src, from the task brief) is
now empty:

  grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts"

* docs: plan 4 — the repository layer is the only data access path

DatabaseService/DatabaseModule are gone; queries go through repositories
(server/src/db/repositories/) and UnitOfWork (server/src/nest/database/
unit-of-work.ts) for transactions. MikroORM's migrations are the only
schema source, including for tests — the hand-kept schema.ts/migrations.ts/
seeds.ts test builders and their parity test are deleted. Documents the
db/database.ts (connection only) / db/orm-driver.ts (bound driver) /
UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints,
the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/
check:entities) as the schema-parity guard. Corrects two stale nest/README.md
mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1
trip-access bullet) and marks the legacy src/services/ migration recipe as a
historical changelog rather than current guidance.

* refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters

Removes the now-dead DatabaseService injection (or private get db() getter)
from addons.service.ts, trips.service.ts, trip-invite.mcp.ts,
place-photo-cache.service.ts, trek-photo-registration.service.ts,
categories.mcp.ts, journey-domain.service.ts, plugins.service.ts,
plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts
and packing.service.ts — every one of them was fully repository-backed
already (Task 2/3's facade inline sweep), just still carrying the param.

PackingService's TripMembersRepository is required now (the
this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's
uow is no longer @Optional() — both moved ahead of the optional
registry?/hostFactory? params, since TypeScript refuses a required
parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow
is required to match, and its upsert() always runs the descriptor
refresh inside one transaction instead of falling back to an
un-transacted sequence.

user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's
"stays raw" plugin-table trio) moves off DatabaseService onto a new
MaintenanceRepository.deletePluginUserData method — the rule-4-permitted
connection.execute() home, built from a directly-injected EntityManager.
Threads the active UnitOfWork.transactional's transaction context through
execute()'s ctx param: without it, a raw connection.execute() call issued
from inside deleteUserCompletely's open transaction contends for the same
single-connection checkout the transaction already holds and deadlocks
(never released, since the transaction is itself awaiting the call).
tests/unit/db/transaction-yield.test.ts's probe B already pinned this
exact hazard class for a raw better-sqlite3 statement; this is the same
mechanism reached through the ORM's connection pool instead.

nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim)
is deleted, with every importer repointed to trek-photo-registration.service
directly. The ~7 files that only imported the TripAccess/PlaceWithTags
type re-export from database.service.ts now import them from
Trips.repository.ts/Places.repository.ts directly, the actual single source.

* refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts

Grep gate confirmed zero src/ importers of DatabaseService left outside
these three files (Task 1/2/3's earlier conversions + this task's own
vestigial-param sweep). Deletes database.service.ts, database.module.ts
and database.tokens.ts, drops DatabaseModule from app.module.ts's imports
and the five other modules that still listed it
(accommodations/calendar/doc-sync/feeds/trip-members) — none of them had
a live DatabaseService injection left, so the import was already dead.
OrmModule is the module that survives DatabaseModule's deletion, per the
Phase 0 ledger's own ruling; untouched.

db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test
files' vi.mock('…/db/database', …) factories return a { db: <mock
handle>, … } shape, and their own `import { db as testDb } from
'…/db/database'` is typed against this file's real exports regardless of
the runtime mock — vi.mock swaps the module at runtime, but tsc resolves
the import statically. Deleting the export would fail typecheck:tests
across the whole suite for a test-harness idiom this task's file set
doesn't own (Track B's territory). src/ has zero remaining importers, so
the ESLint no-restricted-imports gate this task also shrinks still holds
this file to its permanent allow-list entry either way.

* test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule

Mechanical follow-through of the previous two commits across ~110 test
files:

- The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their
  partial Test.createTestingModule graphs; the import and the array entry
  are dropped (nothing in any composed domain module injected
  DatabaseService any more, so the entry was already dead weight).
- The shared test helpers (mcp-test-controllers.ts, plugin-host.ts,
  accommodations-service.ts, notifications.ts, test-addons.ts,
  test-uow.ts) each held a DatabaseService/DatabaseService-shaped param
  used only for its raw better-sqlite3 handle; every one now takes that
  handle directly, and every constructor call site the helpers make
  (PackingService, PlacesService, JourneyDomainService,
  PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService,
  UserCleanupService, AddonsService, PluginsService, PluginRuntimeService)
  drops the removed param and, where PluginRuntimeService's uow is
  involved, reorders it ahead of the optional registry?/hostFactory?
  params to match the real constructor.
- ~60 further unit/integration test files hand-constructed one or more of
  those same services directly; same fix, file by file. Four of them
  (memories-access.service.test.ts, places.directions.test.ts,
  places.service.test.ts, unified-memories.service.test.ts) also drop a
  vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner'
  | 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those
  methods delegated through DatabaseService — dead now that the services
  read the owning repository directly.
- database-service.test.ts and database-service.delegation.test.ts tested
  DatabaseService directly and are deleted with it.
- tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw
  DatabaseService statement's dirty-read behaviour inside an open
  transaction (rule 24), now issues the same raw better-sqlite3
  statement directly against the shared test handle — the exact shape
  DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(),
  bypassing MikroORM/Kysely entirely) — so the hazard it documents is
  unchanged. Verified still green.

No expect(...) assertion changed in any file; only construction/import
plumbing. typecheck, typecheck:tests and the full touched-file run
(101 files / 2668 tests) are green, plus the compiled boot
(bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real
e2e boot file (trips.e2e.test.ts).

tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this
commit — see the next one.

* test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix

This file arrived already modified and unstaged in the shared tree —
Task 6 (the parallel legacy-schema-deletion task) had swapped its DB
harness onto createSnapshotTestDb() and updated the UserCleanupService
construction to this task's new EntityManager-first shape while landing
its own commits. Finished here: USER-CLEANUP-005 ("survives a slim
schema without the plugin tables") passed a fake `{} as unknown as
EntityManager`, which doesn't reach the real UC1 path any more (the
previous raw DatabaseService.run swallowed everything in a try/catch
regardless); it now builds a real EntityManager bound to the slim
handle via sharedTestOrm(slim), so the per-statement query against the
missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state
tables is what's actually being proven caught, not a construction-time
throw.

USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them
exercising UC1 inside the real uow.transactional wrap) are the
regression test for the previous commit's deadlock fix in
user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every
one of these hung and timed out. Green now, verified directly.

* chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list

Per ruling 6: database.service.ts's entry comes off (the file is
deleted). The five plugin entries (plugin-route-normalize.ts,
plugin-host-state.ts, install/discovery.ts, settings-defaults.ts,
signature-status.ts) come off too — re-verified none of the five import
better-sqlite3 or DatabaseService any more (3j's own conversion). The
src/demo/** blanket entry was already gone (3i's own task landed it).

reseat-booked-nights.ts and document-provider-seed.ts stay, with the
`// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7),
invoked from inside numbered, already-shipped MikroORM migrations, never
Plan 4's or any future plan's to touch.

Permanent allow-list after this commit: src/db/database.ts,
src/db/orm-driver.ts, src/db/durability.ts (the driver homes),
src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a
plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts
(3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts
and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts
entries were already removed by Task 6 when it deleted those files.

0 new lint errors; warning count unchanged at 2123 (well under the 2247
lean-gate ceiling) — every file the shrink touches was already fully
converted.

* refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:settlementId/:userId route param in BudgetController is parsed ONCE at
the gate with toRowId, matching the legacy affinity-seam "not found" outcome
for a malformed id on each route (404 'Budget item not found'/'Settlement
not found', except toggleMemberPaid — the one legacy handler that never
404'd an unknown item/member either, so a malformed id there skips the write
and keeps the same 200 { member } + Number(id)/Number(userId) broadcast
shape it always had).

The parsed numbers thread down through BudgetService's update/updateMembers/
setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and
their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/
applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's
getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById
and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById,
all narrowed from number | string to number; trip_id stays loose, a
separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no
longer needs its String() wrapper on the update/delete calls; budget.mcp.ts
was already Zod-typed to number throughout. Two new e2e tests pin the
non-numeric-id 404 at the gate; controller/service unit tests cover the
narrowed call args and the toggleMemberPaid malformed-id parity case.

* refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:bagId/:templateId/:userId route param in PackingController is parsed
ONCE at the gate with toRowId, matching the legacy affinity-seam "not found"
outcome for a malformed id on the routes with a pre-ORM Express precedent
(update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers ->
'Bag not found', applyTemplate -> 'Template not found or empty').

#858's sharing/contributors routes (setSharing/clone/addContributor/
removeContributor) are native Nest code with no pre-ORM precedent (landed in
7eabf6066, after the migration) — a malformed id there just 404s the same
way an unknown one already does. removeContributor's :userId used to reach
contributorsRepo.deleteOne through a bare Number.parseInt(), the exact
NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too.

The parsed numbers thread down through PackingService's updateItem/
setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/
getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed
from number | string to number; tripId stays loose, a separate,
already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/
update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/
update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply
narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer
need their String() wrappers on the update/delete/updateBag/deleteBag/
setBagMembers calls; packing.mcp.ts was already Zod-typed to number
throughout. Two new e2e tests pin the non-numeric-id 404 at the gate;
controller/rpc unit tests cover the narrowed call args and the
removeContributor malformed-userId parity case.

AdminPackingTemplatesController (/api/admin/packing-templates) is out of
scope — a separate admin-gated surface with its own string-typed template
CRUD, not part of this trip-scoped controller's :id chain.

* test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3)

* fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1)

An install the retired positional runner migrated has a schema_version row
and no mikro_orm_migrations rows, so its first boot on this branch replayed
all 244 migrations over an already-migrated schema: eight refuse the boot,
and the replay-safe ones overwrite user data (step 26 rewrites every
day_assignments.assignment_time).

runSchemaBootstrap now records the baseline plus every migration whose
"Legacy migration step N" docstring is <= schema_version as executed, in one
transaction through the migrator's own storage, before the ordinary run
applies the rest. The step map is derived from the discovered migrations'
source, never hand-kept, and anything unmappable (a newer or malformed
schema_version, a duplicated, missing or out-of-order step) refuses the boot.

* test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline

SQL dumps of databases the retired runner itself built: schema_version 205
(main at fa406b708), 242 (417d0445b, the runner's last commit) and 82
(v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead
and run for the first time). Each gets canary rows: user-set assignment
times and a booked night.

buildApp() on each asserts the baseline and the applied rest in order, the
assignment times untouched, the booked-night reseat run exactly once on 205
and 82 and not at all on 242, schema_version kept, no half-built *_new
table, a schema identical to a fresh install's, and an idempotent re-run.

* refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1)

demo-reset.ts was the one src/ consumer of the db Proxy left, reached
through a runtime require that a grep on `from` misses. It now imports
closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts
does, so the Proxy genuinely has no src/ importer (the claim in database.ts
and server/CLAUDE.md now holds). The path test mocks the module with vi.mock
instead of teaching Node's require loader to answer for a .ts file.

* test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3)

ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and
the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so
each id names one test again. No test body changes.

* refactor(server): derive the two restated public row types instead of copying their columns (m4)

JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen
columns with only the parent key changed; it is now that row with
journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely
table shape restated PlaceRow's columns; both now Pick them off PlaceRow.
Types only. The review's CPD method over Plan 4's new src lines drops from
about 3.4% to 0.64%.

* test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5)

Neither read had any test reference. Each is now pinned with
toEqual(<legacy statement run raw on the same connection>), the
listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus
a missing id (TRIPREPO-067/068), and VC90 over one world exercising every
exclusion (caller, guest, existing share, plan owner, accepted member) and
a missing plan.

* fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1)

The legacy runner ran the running release's createTables() on every boot
before its pending steps, and about 30 tables exist only there. Marking the
baseline migration as executed left installs older than those tables (below
legacy step 68) failing with "no such table: photo_provider_fields" and, since
the rows were committed before the run, stuck half-baselined for every later
boot.

Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs
first, as the legacy boot did. The rows are logged inside the same
transaction as migrator.up({ transaction }), so a failed first boot records
nothing and the next boot baselines again.

A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied,
canaries kept. Its two schema differences from a fresh install
(users.immich_access_token, reservations.accommodation_id INTEGER) come
from the legacy chain itself and are pinned as carried.

* fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames

feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.

* ci: give the server lint step an 8 GB heap for the type-aware rules

* test(server): keep the no-promise-as-value rule tester out of single-run mode on CI

With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.

* refactor(server): name the new migrations after the first seven words of their purpose, like the rest

* fix(server): keep tsx a production dependency so image key rotation can load it

* test(server): re-pin the repositories branch floor at the measured 89.83%

* test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom

* test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL

* test(client): keep console output for failing tests only and answer the health probe

* test(client): drop the health handler, it races the admin page's first load

* chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-10-06 19:27:30 +02:00
Maurice 46fcd45eb6 fix(tours): space the tour row actions apart 2026-10-06 17:25:32 +02:00
Maurice 5a249e0ca9 fix(tours): match the delete button to the add-to-day button and use the TREK tooltip 2026-10-06 15:48:00 +02:00
elmocito 9b73ede60c fix(tours): preserve viewport when starting a new tour 2026-10-06 15:22:07 +02:00
elmocito b1289c67b8 test(tours): fix assignment test validation 2026-10-06 15:16:23 +02:00
elmocito a05855ee2b test(tours): align assignment coverage with row action 2026-10-06 15:09:50 +02:00
elmocito 99f3bbfb09 fix(tours): simplify post-save confirmation 2026-10-06 13:44:08 +02:00
elmocito 411a6052fe test(planner): stabilize day auto-scroll tests 2026-10-06 13:41:07 +02:00
elmocito 8739b4c74d fix(tours): restrict permanent deletion to tours workspace 2026-10-06 12:52:25 +02:00
elmocito 04c7491e2a fix(tours): preserve map viewport during route updates 2026-10-06 00:44:43 +02:00
elmocito af7dbce105 fix(tours): prevent duplicate day assignments 2026-10-05 23:53:11 +02:00
elmocito a9f9a23908 test(tours): align locale count after copy cleanup 2026-10-05 20:47:04 +02:00
Maurice 040e6f2405 fix(tours): center the empty tour list in the Tours tab like the planner rail 2026-10-05 12:12:54 +02:00
Maurice c92478c5ad feat(tours): align the Tours UI with the planner redesign
The Tours surfaces now use the same building blocks as the rest of the
planner: tinted head bands, eyebrow labels, white field boxes and pills,
place-style list rows and the inspector frame for the map detail.

- Tour detail on the map uses the place inspector's frame, head band,
  labelled metric fields, collapsible track colour and footer buttons
- Tour rows in the Plan sidebar and the Tours tab look like place rows,
  with the facts as pills and a "+" that opens the trip's days as a menu
- Tours tab rails get the planner head bands, editor fields and
  waypoint rows in the day card style; notices share one look
- Map hint shortened to one line and the empty state reads as a single
  sentence in all locales
- New "tours" mascot scene with a pack, a trekking pole, hills and a
  trail, used by the Tours empty states
2026-10-05 12:04:49 +02:00
elmocito ccaed9f9ba test(tours): align sidebar fixture with planner-owned filter reset 2026-10-05 07:54:44 +02:00
elmocito 2ec5ae7e4d merge: update Tours branch with current Places filters 2026-10-05 01:50:47 +02:00
elmocito 0c2dbdf03b test(tours): update planner wiring and stabilize date fixture 2026-10-05 00:55:58 +02:00
EvKoh b6115d48f3 refactor(places): one name for the uncategorized bucket and the category matcher
PlacesSidebarHeader uses the UNCATEGORIZED constant, placesBrowserModel stops
re-exporting matchesCategoryFilter (its test imports it from utils/placesFilter),
and the orphan comment in the en places strings joins the places.filters key.
2026-10-04 16:20:17 +02:00
EvKoh 4b263c8e4b fix(mobile): align the places browser's filter panel with the map control
The panel button is named "Filters" like the map control, its badge counts
kinds of filter through countActivePlacesFilters (the pool, shown as chips
above it, stays out), and picking a rating floor clears the selection as
picking a pool does.
2026-10-04 16:20:17 +02:00
EvKoh 3d99e569ea test(map): keep the places filter on a road trip's plan map without Dawarich 2026-10-04 16:20:17 +02:00
EvKoh eee361ef28 docs(places): say where the map's places filter departs from the lists
The header claimed the lists and the map always agree. They apply the same
predicate to the same values, but the map narrows "planned" to the open day
(#2024) and leaves collapsed days' stops off.
2026-10-04 16:20:17 +02:00
EvKoh db3c9d660e fix(mobile): announce which Show and rating chip is chosen
MChip only showed its state through colour. A pressable option renders
aria-pressed from active; the places filter's Show and rating floor chips use
it, and the tests now check the chosen chip by its pressed state.
2026-10-04 16:20:17 +02:00
EvKoh 3951e8cc9a fix(map): announce the places filter control as opening a dialog
MapTogglePill always set aria-pressed, so the phone map's Filters control read
as a toggle although it opens a sheet. An opensDialog option drops aria-pressed
for aria-haspopup="dialog"; the other map toggles are unchanged.
2026-10-04 16:20:17 +02:00
EvKoh a9f7adf99b fix(map): move the tracks filter fallback to the always-mounted planner hook
The fallback from "Tracks" to "All" once the last track disappears lived in
the desktop sidebar and the phone places browser, neither of which is mounted
while the phone map is in front: the map could sit empty under a filter no
control offers any more. useTripPlanner now owns it, once, for every screen.
2026-10-04 16:20:16 +02:00
EvKoh b5514024fa docs(wiki): describe the map's places filters
Map-Features gets a "Filtering the places on the map" section: the markers
follow the places list's Show, category and rating filters, and on a phone
the Filters button of the plan map opens the same choices in a sheet.
Places-and-Search says the rating floor narrows the map markers too.
2026-10-04 16:20:16 +02:00
EvKoh fe5a8a8854 fix(map): make the places filter sheet opaque
The sheet opens over the phone map, and with the default bar glass the map's
place names showed through the pool chips, the rating floors and the category
rows, which made them hard to read at 390px wide. It now uses the opaque sheet
material, like the other menus that open over busy content.
2026-10-04 16:20:16 +02:00
EvKoh 66f11602b2 feat(map): filter trip places from the mobile map
On a phone the only way to narrow the trip's pins was to leave the map for
the places list, and the rating floor could not be set at all. The phone map
now has a round "Filters" control at the foot of its right-hand stack, badged
with how many filters are on, opening a sheet with the pool (all / unplanned /
planned / tracks), the minimum rating (the desktop list's floors), the trip's
categories including "no category", and a reset. It is hidden on the road trip
stage, which draws its own pins.

The places browser's filter panel gains the same rating floor; its category
rows move into MPlacesFilterControls so the panel and the sheet share one
copy. Everything writes the trip store, so list and map stay in step.
MapTogglePill takes an optional count badge.
2026-10-04 16:20:16 +02:00
EvKoh a44c966f4d feat(places): share the rating floor between the lists and the map
The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
2026-10-04 16:20:16 +02:00
Maurice b6c3b899d5 test(planner): pin a weekday in the opening hours inspector test so it passes on Sundays 2026-10-04 15:52:50 +02:00
Maurice 5bcb377b9c fix(mobile): trim the note wrap classes to one rule and cover the kept line breaks
overflow-wrap:anywhere alone lets a long URL wrap inside a flex row; break-words
is overridden by it and word-break:break-word is deprecated. MarkdownText already
gets the rule from .collab-note-md, so its call sites drop the duplicate.
2026-10-04 15:46:10 +02:00
Diogo Mendes a9c78f680e fix(mobile): prevent layout breaking from long unbroken text in notes 2026-10-04 15:46:09 +02:00
elmocito 562d8c41d2 merge: update Tours branch to current dev 2026-10-04 13:39:04 +02:00
Maurice a856c68f87 docs(helm): list PLACES_API_KEY and VAPID_PRIVATE_KEY among existingSecret keys 2026-10-04 13:36:34 +02:00
Oscar Carlsson 4f2f937e17 Clarify how the existingSecret really works 2026-10-04 13:36:17 +02:00
Luffy c7793a6017 fix(email): link notification preferences to settings tab
- Make the “Manage preferences” text in notification emails clickable.
- Link directly to `/settings?tab=notifications`.
- Preserve the localized link text and existing email styling.
- Add a regression test for the generated preferences URL.
2026-10-04 13:35:58 +02:00
elmocito 9e475f447a feat(tours): add non-motorized day tour planning 2026-10-04 12:59:55 +02:00
Maurice c93cf2bce9 docs(help): new pictures for every screen that changed, guides for the release's new tasks, texts and translations to match 2026-10-01 22:09:58 +02:00
Maurice 05b53d024e docs(wiki): bring every page up to the next release; admin settings, user defaults and notifications get pages of their own 2026-10-01 19:40:22 +02:00
Maurice cf3818cb66 feat(settings): confirmations in the shared dialog, token colours on About, scope picker in the new look 2026-10-01 16:59:09 +02:00
Maurice 56dfd56bab fix(settings): stacked rows take no height from their basis; grouped navigation without a second heading; white admin page 2026-10-01 16:59:09 +02:00
Maurice 788a58679e feat(settings,admin): every tab and popup in the planner's design 2026-10-01 16:59:09 +02:00
Maurice 4bc7c5cf68 feat(settings): a settings kit in the planner's language; cards, navigation and header 2026-10-01 16:59:09 +02:00
Maurice d7bf7f2f16 fix(background-tasks): no state update after unmount when the features answer arrives late 2026-10-01 16:54:28 +02:00
Maurice 5669f8b860 fix(places): e-mail check without a backtracking regex; explicit grouping in the no-proxy match 2026-10-01 16:10:33 +02:00
Maurice 8f4accc8a5 test(calendar): a test id of its own 2026-10-01 15:33:51 +02:00
Maurice 1e4cc85750 test(calendar): per-leg zones from one endpoint per airport (#2389) 2026-10-01 15:33:38 +02:00
Maurice 62950aed24 test(e2e): hand-made schemas follow the new columns; placing entries never blocks the refresh 2026-10-01 15:18:30 +02:00
Maurice 39074f6d46 fix(places): hours can be typed, and an edited place takes over the looked-up hours; quota as a badge 2026-10-01 15:18:30 +02:00
Maurice 647bb33565 fix(costs): centre the amount/percent switch on its hint 2026-10-01 14:55:42 +02:00
Maurice d694fc68f3 chore: journey type fields for the test typecheck, prettier on the new shared schemas 2026-10-01 14:37:06 +02:00
Maurice b6b4904664 feat(bookings): cruises list their ports of call with arrival and departure (#1807) 2026-10-01 14:31:12 +02:00
Maurice 8d684b48cc fix(journey): the photo-location switch marks its save as unawaited 2026-10-01 14:20:27 +02:00
Maurice db4337287b fix: mark deliberately unawaited promises with void (Sonar S9383) 2026-10-01 14:19:52 +02:00
Maurice f9baf3395e feat(studio): add photos from Immich and Synology like the journey gallery (#2271) 2026-10-01 14:19:51 +02:00
Maurice 81d7022b96 feat(journey): optionally place an entry where its first photo was taken (#1003) 2026-10-01 14:05:47 +02:00
Maurice f875c85474 feat(journey): geotagged photos on the journey's own map, both renderers (#2453) 2026-10-01 13:59:10 +02:00
Maurice f4d81ea42a feat(places): an empty search offers adding the place by hand (#2472) 2026-10-01 13:47:04 +02:00
Maurice d61d8ca9d7 feat(places): phone, e-mail and opening hours by hand when the search has none (#2472, #2356) 2026-10-01 13:42:44 +02:00
Maurice 8c37597bd7 feat(dashboard): search every trip by title, date or a place on it (#2190) 2026-10-01 13:30:44 +02:00
Maurice 99c62878f3 feat(pwa): a relaunch also returns to the plan day it was on (#666) 2026-10-01 13:22:37 +02:00
Maurice d1f4deaa31 feat(pwa): reopen the trip and tab the installed app was in (#1024) 2026-10-01 13:18:47 +02:00
Maurice 1f464ce805 feat(places): sort the places list by name, rating, age or last change (#2093) 2026-10-01 13:15:02 +02:00
Maurice a98449d573 feat(planner): ask before a stray click throws away an edited place or booking (#2253) 2026-10-01 13:11:09 +02:00
Maurice 477f41693d feat(journey): set a journey's status by hand (#762) 2026-10-01 13:03:46 +02:00
Maurice 3063606f56 feat(costs): enter a custom split in percent (#1709) 2026-10-01 12:58:31 +02:00
Maurice 855c0a4b24 feat(settings): optional preferred map app for the navigate button (#2423) 2026-10-01 12:52:58 +02:00
Maurice a416d41cda feat(packing): templates keep weight, quantity and bag; packed and per-person weights (#1131) 2026-10-01 12:47:58 +02:00
Maurice 88afdb986c feat(mcp): upload_trip_file tool for adding documents to a trip (#1566) 2026-10-01 12:39:58 +02:00
Maurice 1c34a74498 feat(calendar): one feed event per leg of a connecting flight or train
A flight or train with several legs comes out of the calendar feed as one event per leg, titled with its route and carrying the leg's own departure, arrival and segment reference, so the layover shows as the gap between them. A booking with a leg missing its departure time stays one event, as before. (#2389)
2026-10-01 12:32:42 +02:00
Maurice f4626c9f3e feat(notifications): admin defaults for users, and channels blocked for everyone
Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
2026-10-01 12:28:36 +02:00
Maurice 7acf7f5655 feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582)
2026-10-01 12:15:38 +02:00
Maurice 5138358d7b feat(net): the SSRF-guarded requests go through HTTP_PROXY / HTTPS_PROXY too
Place photos, link previews, webhooks and the integrations fetch through the guard's own dispatcher and so ignored the outbound proxy, which left a server that can only reach the internet through one without them. They now read HTTP_PROXY, HTTPS_PROXY and NO_PROXY themselves and connect through the proxy, after the guard has vetted the target. The Helm chart passes the three variables through. (#1754)
2026-10-01 12:00:01 +02:00
Maurice 885896aba2 feat(files): FILE_UPLOAD_LIMIT_MB sets the upload limit for files and notes
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
2026-10-01 11:56:14 +02:00
Maurice 750b178d5b test(client): follow the password checklist and the labelled modal close in three suites 2026-10-01 10:43:53 +02:00
Maurice 52934a8326 fix(mobile): the map compass stands above the base-layer switcher
Beside it the two round controls read as one crowded row over the dock; on the switcher's own left edge, one gap above it, they form a column.
2026-10-01 10:38:29 +02:00
Maurice 94447fa011 test(public-api): the other stats suites stub next_trip too 2026-10-01 10:38:29 +02:00
Maurice ba0cc331c1 feat(mobile): today marked on the day rail, and a button back to it
While a trip is running, today's chip on the phone carries a ring and a dot, and a button beside the rail jumps back to today from wherever it was scrolled, in the plan and on the map. It stays in place, dimmed, while today is open, so the rail never shifts under the thumb. (#2392)
2026-10-01 10:17:29 +02:00
Maurice 48f28b40c4 feat(collections): navigate to a saved place from its sheet on the phone
The phone's collection place sheet gets the Navigation button the desktop detail and the trip's place sheet already have, first among its actions: one offered app opens straight away, more open the same picker. (#2091)
2026-10-01 10:17:29 +02:00
Maurice 9f0fe456a4 feat(planner): the panel widths can be dragged with a finger, also on a tablet
The handles on the day plan and the places panel only listened to the mouse, so on an iPad they did nothing, and in the portrait band they were left out. They now follow a finger as well, show a grip on touch screens, keep the page and the map from taking the gesture, and stay in the narrow layout within the room the map needs. As separators they also move with the arrow keys. (#1012)
2026-10-01 10:17:29 +02:00
Maurice 542a57b74e feat(auth): password rules checked while typing, with the server's own policy
The password policy moves to @trek/shared, where the server keeps enforcing it unchanged and every form that sets a password now runs it before sending: the forced change after the first login, registration, the reset link, the account settings on desktop and phone, and creating or editing a user in admin. Under each new password a checklist ticks off length, upper and lower case, a number and a special character, and a refusal names the actual reason, a too common or too repetitive password included. (#1200)
2026-10-01 10:17:28 +02:00
Maurice 44cc596c39 feat(dialogs): the focus starts inside a dialog and Tab stays there
Every dialog, the shared frame and the older modal alike, moves the focus in when it opens: on a desktop into the first text field, on a phone onto the panel so no keyboard pops up. Tab wraps around inside instead of running through the page behind, and closing hands the focus back to what opened it. Fields that open a list on focus are skipped, and the older modal's close button got a label and a tooltip. (#1302)
2026-10-01 10:17:28 +02:00
Maurice c80ee6b12a feat(planner): reach a hotel's booking from the plan, not only its place
A booked stay's pill in the day head gets a second target that opens the booking; the name still opens the place. The place panel of a hotel, on the desktop and on the phone, lists the stay booked for it next to the bookings pinned to the stop. (#2363)
2026-10-01 10:17:28 +02:00
Maurice 104f0eef38 feat(costs): each expense row shows who it is split between, with their part
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
2026-10-01 10:17:28 +02:00
Maurice b4f0a6dea3 feat(pdf): transport notes in the trip PDF, with a switch in the preview
The notes of a flight, train or rental print under its booking code, like the notes of a place. A Transport notes switch next to Page break per day leaves them out and is remembered; it only shows when a transport has a note. (#1571)
2026-10-01 09:30:43 +02:00
Maurice f7782b9ef7 feat(planner): the places search also finds the description and the notes
The search box of the places list matched only name and address. It now also reads what was written about a place, through one helper the desktop list, its filter counts and the phone's browser share. (#2252)
2026-10-01 09:30:42 +02:00
Maurice 81ebc31698 build(docker): OCI labels on the images, so Renovate links an update to its release notes
The image carries org.opencontainers.image.source, url, documentation, licenses, title, description and version; the release and pre-release workflows add the commit and the build time, and the multi-arch index gets source, version and licenses as annotations. (#1498)
2026-10-01 09:26:27 +02:00
Maurice 1fee38e8f7 feat(oidc): OIDC_USERNAME_CLAIM picks the claim a new account's username comes from
Providers like Pocket ID send the full name in 'name', which became 'JaneDoe'. OIDC_USERNAME_CLAIM=preferred_username builds the username from that claim instead. Unset, or with the claim empty, the old order applies (name, preferred_username, the email's local part), and it is only read when an account is created. (#1677)
2026-10-01 09:25:23 +02:00
Maurice ba3dc65926 feat(public-api): next_trip in /api/v1/stats counts down to the next trip
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542)
2026-10-01 09:22:37 +02:00
Maurice e24da67218 test: finish the qol sweep follow-up, one footer on the public journey, patched image deps
The public journey page drew the old "Powered by" block above the shared
frame's footer, so the credit showed twice; only the shared footer is left.
Its tests read the new frame: the title in the hero, the counts in the hero
pills, the loading skeleton and the cover as an image.

The shared trip tests look for markers inside the map, since the unplanned
card names the same places. The place form clicks the search button by name,
now that a picked place adds the nearby button to that row. The journey
editors send the photo order as one PUT, so their tests follow that request,
and a refused order rolls back as a whole. Payments carry their note, and a
saved mobile entry carries its draft flag.

nodemailer goes to 10.0.13 and brace-expansion to 2.1.7 for the advisories
Docker Scout reports (CVE-2026-102276, CVE-2026-102278, GHSA-v53p-9fqp-m79j).
2026-10-01 09:17:18 +02:00
Maurice d6435572b7 fix(journey): two quick swipes in the photo viewer turn pages instead of zooming
A double tap counted every lift of a finger, the end of a swipe included, so
two quick swipes zoomed in. Only a touch that stays in place is a tap now.
The client tests follow the fields and arguments the qol sweep added.
2026-09-30 23:30:51 +02:00
Maurice f5103d04e0 test: follow the qol sweep in the e2e schemas and the assertions it changed
The hand-written e2e schemas gain the new columns and place_regions, and the
unit assertions take the new arguments and fields. The Public API page names
GET /api/auth/app-config for the running version.
2026-09-30 23:28:14 +02:00
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00
Maurice 91dfab3f4b fix(planner): hand the day plan's unawaited calls to void
Sonar counts a promise that is neither awaited nor caught as a reliability bug, and it reads every such call in a touched file as new. Each of these already reports its own failure (toast, rollback or the sheet's catch), so they are marked as fire and forget rather than changed.
2026-09-29 21:55:03 +02:00
Maurice fd5afb0fad test(map): opening another trip with the overview on frames that trip
The overview holds its camera frame across content edits and only publishes a
fresh one on activation or on a trip switch. Activation is covered; the trip
switch branch was not, so a regression there would leave the map framed on
the previous trip. The new case keeps days and stops identical and changes
only the trip id, so the switch alone has to produce the new frame.
2026-09-29 21:48:27 +02:00
pipipiper 71a8b24f8d fix(map): keep the overview frame when the trip is edited 2026-09-29 21:48:26 +02:00
Luffy 7d22857de8 fix(mobile): restore day selection when reopening a trip 2026-09-29 21:48:26 +02:00
dependabot[bot] 22d5d67023 chore(deps): bump undici from 7.29.0 to 7.30.0
Bumps [undici](https://github.com/nodejs/undici) from 7.29.0 to 7.30.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.29.0...v7.30.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.30.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-29 21:48:22 +02:00
dependabot[bot] b8f2a86987 chore(deps): bump ip-address from 10.5.0 to 10.7.2
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.5.0 to 10.7.2.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.7.2)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-29 21:48:22 +02:00
Maurice 91be785fbc fix(planner): a leg's time zone sits behind a globe instead of beside the route
A departure or arrival row wrote its zone out ("Europe/Berlin") next to the
phase label, and on the narrow day plan that ate the width the route needed,
so "BER → HAM" ended in an ellipsis. The zone is now a small globe after the
time that names it on hover, the time pill carries only the time, and the
route gets its room back.
2026-09-29 21:16:26 +02:00
Maurice 5f700615b9 fix(mobile): files attached while editing a booking are uploaded on save
The phone booking and transport sheets collect picked files and upload them
after the save, but only when the record was new. Editing an existing booking
showed the picked files in the sheet, then dropped them on Update without a
word. The upload now runs after every save that comes back with a record,
through one helper both sheets share. A save that fails uploads nothing.

Closes #2534
2026-09-29 21:16:26 +02:00
Maurice 86243d42af fix(transit): keep the day's hotel in the from/to picks of the transit search
The quick picks of the public transit search listed the day's places first
and the trip's stays after them, then cut the list at eight. On a day with
eight or more located stops the hotel was always cut, so it could not be
picked as the start or the end of a connection.

The stay the day wakes up in and the one it ends in now come first and are
never cut. The day's places and the trip's other stays follow, capped at
eight as before.

Closes #2538
2026-09-29 21:16:26 +02:00
Maurice bd5a6cc748 fix(cors): the instance's own address no longer trips ALLOWED_ORIGINS
With ALLOWED_ORIGINS set, every request carrying an Origin that was not on
the list was failed with an unhandled error, which the exception filter
answered with a 500. A browser sends Origin on every POST and WebSocket
upgrade, including same-origin ones, so opening TREK under any address the
list did not name (the LAN IP, a second hostname, http instead of https)
broke the login with "Internal Server Error" while another browser that
used the listed address worked.

A request whose Origin is the host it was sent to is now treated as
same-origin and always passes, for REST and the /ws upgrade alike. A
foreign origin is still refused, now with a 403 "Not allowed by CORS" and a
warning in the log naming the origin to add.

Closes #2543
2026-09-29 21:16:26 +02:00
Maurice 7b0333a0c1 fix(roadtrip): a booked night no longer shows up mid-day when the day already ends at the stay
Booking a night writes the hotel onto its check-in day as a stop, seated first
or at its check-in, so the drive reaches it even without hotel bookends. With
"Start and end each day at your stay" on, the evening row already ends that day
at the same hotel, so the road trip drove check-out, tonight's hotel, the day's
places and back to the hotel again. Days hides that stop and draws the stay at
the day's edges only.

seatNightBookends now passes over the stop tonight's booking wrote whenever it
seats that stay as the day's evening, in the browser and in calculate_roadtrip
alike. The stops keep their stored index, a hotel the traveller placed stays,
and the booking's stop stays while it is the day's last one or no evening is
seated. With the switch off nothing changes.

Closes #2546
2026-09-29 21:16:26 +02:00
Maurice 5c7aaab3c6 fix(share): the shared map opens framed on the whole trip, not on a corner of it
The opening camera of the public share map was computed for the browser
window, while the map lives in a card a fraction of that size. Leaflet then
rounded the fractional zoom up, and the first fit skipped its correction
because it believed the map had already opened framed. Only picking a day and
going back to the whole trip ran a real fit.

The card is now measured before the map mounts and the opening frame is
computed for it, on a whole zoom level, with the same padding and zoom cap the
later fits use. A card without layout falls back to the first fit.

Closes #2549
2026-09-29 21:16:26 +02:00
Maurice ec06035855 fix(collab): a long link title wraps instead of being cut off
The link chip held its title on one line with an ellipsis, and nothing in
the UI showed the rest: the tooltip names the action, not the link, and a
phone or the PWA has no hover at all. The title now wraps and the chip grows
in height, the host drops to its own line under a wrapped title, and the
chip keeps rounded corners instead of pill ends once it is taller.

Closes #2550
2026-09-29 21:16:26 +02:00
Maurice 64087512df fix(planner): the plan's costs follow the expenses in Costs
The day pills, Total Cost at the foot of the plan and the same figures in
the PDF added up the price column of every planned place. No form sets
that price since expenses replaced it, and nothing in Costs changes it,
so deleting an expense left the plan's total where it was, and a place
planned on two days counted twice.

With the Costs addon on, the plan now adds up the expenses: each one once,
on the day its booking starts, else the first day its place is planned,
else its date, and in the total either way. A foreign expense counts at
the rate frozen when it was entered and a refund nets against its day,
so Total Cost matches Total trip spend in Costs. With Costs off, a
place's own price still counts, once per place.

Closes #2551
2026-09-29 21:16:25 +02:00
Demisu f83201f976 fix(helm): quote Secret stringData values 2026-09-29 21:16:25 +02:00
Maurice 26910ac6fb fix(packing): hand the list's unawaited calls to void
Sonar counts a promise that is neither awaited nor caught as a reliability bug. Every one of these already reports its own failure (toast or optimistic rollback), so they are marked as fire and forget rather than changed.
2026-09-29 21:04:00 +02:00
Maurice 272df55cd4 feat(packing): sort the packing list A to Z
A switch at the end of the list's tab row shows every list's items in name
order, so checking whether something is already on the list no longer means
reading all of it. The order follows the reader's language (accents beside
their letter, "Shirt 2" before "Shirt 10") and the empty list stand-in stays
at the bottom.

Sorting only changes what is shown: the manual order stays stored and comes
back with a second click. While sorted the drag grips step aside, the box
keeps its column, and the choice is remembered per browser.
2026-09-29 20:25:25 +02:00
Maurice 6f61b66c5c fix(share): the day line on the shared map asks for a picked day as a boolean
`selectedDay && ...` would render a 0 if a day id were ever 0; the
check is explicit now.
2026-09-28 21:55:38 +02:00
Maurice fe93df7711 docs(help): new pictures for Collections, Vacay, Atlas and Journey
Every guide of the four screens is recorded again with the new dialogs.
The guide scripts follow the new markup, and a review of the pictures
fixed what they taught wrong: the Kyoto list holds Kyoto places with
their photos, the invite and share steps show the picked user, the shared
calendar step shows one shared with you, the Dawarich and wishlist steps
frame the whole dialog, and the create journey guide links a trip with
places.
2026-09-28 21:22:47 +02:00
Maurice 2c486e6832 fix(journey): the Studio export dialog in the new look, and the Atlas country card draws its flag
The export dialog was the last popup on the old card: it is now the shared
dialog frame with its options as rows, and it takes Escape for itself so
the key no longer reaches Studio behind it. Its stylesheet goes.

The Atlas country card printed the flag emoji, which Windows draws as the
two letters of the country code; it uses the shared CountryFlag now.
2026-09-28 21:22:46 +02:00
Maurice acd003ea67 feat(dialogs): Vacay, Collections, Atlas and Journey in the new dialog look
Every popup of the four pages moves to the shared dialog frame the trip
uses: head band with its icon, the scrolling body, the footer with its
actions, and ConfirmDialog for the questions.

- Vacay: settings, removing a year (now says so when it fails), the
  fusion request, inviting a person, a person's colour and sharing the
  calendar.
- Collections: new and edited lists, adding a place, importing a file and
  a trip's places, sharing, labels and assigning them, copying to a trip,
  moving or duplicating, saving a trip's places and deleting a list. The
  stylesheet of the old import view goes.
- Atlas: the country popup with the flag in its head, its choices as rows
  and the wishlist step; Dawarich's Atlas dialog and its accept dialog on
  the desktop.
- Journey: creating a journey, its settings, linking a trip, inviting a
  contributor, the entry editor (discarding asks in a dialog) and the
  photo provider picker; the entry card's menu is the shared one.

The phone keeps its own sheets: where a dialog is shared with it
(Dawarich, the contributor invite, the provider picker) only the desktop
frame changes.
2026-09-28 16:52:32 +02:00
Maurice 9064ee57b7 test(share): the map test's page hook knows the folded days and the map's day pick
The redesigned share page reads collapsedDays, toggleDay and pickDayOnMap
from its hook; the map test's stand-in left them out and every case there
failed on the missing set.
2026-09-28 16:52:32 +02:00
Maurice bbf8d94f9f feat(share): the public trip page in the planner's design
The page a share link opens is rebuilt from top bar to footer: the
planner's light top bar with the language picker, the trip as a hero
made of its cover, a tab bar that stays in reach, the days as the
planner's cards beside a map that stays in view, bookings as the
booking cards, packing and costs as cards with their progress and
shares, the chat as bubbles, and empty states for sections the owner
shared with nothing in them yet.

The map picks its day in a select with a step back and forth instead
of a row of chips a three-week trip did not fit into.
2026-09-28 14:40:34 +02:00
Maurice 21c068eb43 fix(trips): the Share dialog sits in the middle of the window like the other dialogs 2026-09-28 13:39:58 +02:00
Maurice 3901aecbc0 feat(trips): Share and Save to list in the new dialog look
Share opens on the shared dialog frame: the trip's name and the number of
people in the head band, members and guests on the left, the public link
and the invite link as two cards on the right. Rows carry their actions as
icon buttons named by their tooltips, and removing a member, leaving,
handing over the trip or removing a guest asks in the app's own dialog
instead of the browser's. The invite link can no longer be created twice by
a double click. The phone, which opens the same dialog from its More sheet,
keeps the sheet it had; both run on one set of hooks.

Save to list has the place's name in the head band, Mark visited as a pill
beside it, the lists as rows with a tick, and View at its foot.

The public link's hint loses its dash in every language, and the help
guides and wiki pages of the Share dialog show the new look.
2026-09-28 13:19:04 +02:00
Maurice af9710c618 docs(wiki): the pictures that were missing, and the follow-ups of the rewrite
The wiki pictures of the trip are taken again with the fixtures the help
guides use, so the Bookings tab shows a booking of every kind, Files has
documents and What's Next has something ahead. New pictures: the map's
hover card, the Subscribe to calendar dialog, the PDF preview, the To-Do
view, and the Transports tab's own timeline and booking detail. Day
selection in the screenshot run clicks the day's number, now that the
middle of a day header is the booked night.

Home, Real-Time Collaboration, Road Trip, Map Features and the bookings
page are brought in line with the rewritten pages, and sixteen assets no
page uses any more (mostly old GIFs, 22 MB) are removed.
2026-09-28 12:19:49 +02:00
Maurice 146fe4a063 docs(help): the trip screens' guides for the new layout, with new pictures
The guides of the map, bookings, costs, files, collab, lists, road trip and
transports now describe the desktop as it is after the redesign: the bars
with their icon buttons and filter popovers, the dialogs with the title in
the head band and status and type as pills, the three booking views and
the booking detail, the compact note cards, the Costs table. Two guides
are new: the booking views (cards, list, timeline, the Trip and Day zoom
and the detail) and the Costs table view. Every changed text is translated
into every locale, and the Plan tab texts call the bookings tab by the
label it shows, Book.

Every picture of the thirteen trip screens is taken again against the new
layout, on one picture day. Scripts follow the new markup; the Dawarich
fixture uploads its recording again when the stored one belongs to another
run, and the spare poll gets its vote from a member so its winner shows
green.

server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so
several recordings on their own port pairs can share one dist.
2026-09-28 12:19:30 +02:00
Maurice c88f9a4a23 fix(i18n): labels the translators flagged, and two dashes in the UI
vi called the Tracks filter "songs", sv the pending status "commuting", nl
the open-now pill "opening hours", and tr showed "its plan" on the Plan tab
and "being suspended" for pending. The road trip strings added in August
had lost their accents in eight languages (Arrive and Leave, the stay
buttons, the whole day and halfway filters, the avoidance options), uk had
a Latin i in "halfway", hu and tr called No tolls "free of charge", and az
put an en dash between the two ends. sv and vi said "I owe" for I'm owed and
You're owed, sv had "Namnn" as the table's Name, and th said "split
equally" for Split matches total.

The climate hint of the day details and the Unfinished tooltip in Costs
lose their dash in every language.
2026-09-28 12:18:47 +02:00
Maurice 350a144457 fix(ui): full-height document previews, and tooltips only on keyboard focus
The PDF preview on the Files tab and the document preview of a Collab note
were about 150 px tall: the object carried flex-1, and in a column without a
fixed height that falls back to the object's own height instead of 74vh.

A tooltip no longer opens when a closing dialog hands the focus back to the
button that opened it. It floated over whatever came next, at the place the
button had before the page moved. Keyboard focus still opens it.

The table view sorts its dates with localeCompare, and two NaN fallbacks and
an unused import go.
2026-09-28 12:17:15 +02:00
Maurice 33b440706e fix(weather): spell the German descriptions with their umlauts
Bewölkt, Teilweise bewölkt, Überwiegend klar and Schneekörner were written without their umlauts and reached the day details and the planner that way.
2026-09-27 23:26:42 +02:00
Maurice 950029ccc4 fix(weather): read a past day of the day details from the archive
The detailed weather asked the forecast API for every day up to 16 days out,
past days included. Once a day is a couple of months back that API answers
nulls instead of an error, and the day details showed 0° while the planner,
which already reads the archive, showed the real figure.

A past day now comes from the archive, with the same daily figures, sunrise,
sunset and hourly series as the climate path, which shares the new helper. A
recent day the archive has no figures for yet goes on to the forecast API as
before, and nulls from either source now answer no_forecast instead of 0°.
2026-09-27 23:21:28 +02:00
Maurice cb7014b378 feat(costs): table view, and the wiki and help center for the new trip page
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.

On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.

The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
2026-09-27 23:15:19 +02:00
Maurice 9f5e332293 test(reservations): pick the confirm button without Array.at, which the ES2020 lib lacks 2026-09-27 21:06:02 +02:00
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00
Maurice acd0d6511e feat(reservations): desktop bookings and transports in the phone's look
The Bookings and Transports tabs on the desktop take the phone's card
language: a tinted status band, the type chip and framed fields. One bar
holds the search, a filter popover for status, types and travelers, the
view switch and the view options.

Three views over the same filtered entries: cards (four per row at most),
a dense list grouped by day, and a timeline with a trip scale and a day
scale by the hour. A booking opens as a dialog with everything it holds,
the booking code blurred by the existing setting, and "On map".

Work in progress: only en and de are translated, and tests, help guides
and the wiki still follow.
2026-09-27 00:34:33 +02:00
Maurice 237f42a493 fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.

The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
2026-09-26 15:59:05 +02:00
RemEx b1c05bca83 feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
2026-09-26 14:38:01 +02:00
Maurice 706c6c3959 Merge pull request #2531 from liketrek/fix/v4.3.3-dev
fix: bring the v4.3.3 fixes to dev
2026-09-26 14:29:11 +02:00
Maurice 6c0d08a415 Merge branch 'main' into fix/v4.3.3-dev
Brings the v4.3.3 release back into the dev line, so the 4.4.0 release
merges into main without conflicts. All twelve conflicts resolve to the
dev side, which carries the same fixes adapted to dev (migration order
of #2527, dev only code around the costs and PWA fixes). Against the dev
fixes alone, the only change is the version bump to 4.3.3.
2026-09-26 14:23:45 +02:00
github-actions[bot] 1c05ee4b3a chore: bump version to 4.3.3 [skip ci] 2026-09-26 12:23:05 +00:00
Maurice 1ff22490f2 Merge pull request #2529 from liketrek/fix/v4.3.3
v4.3.3 bug fixes
2026-09-26 14:21:56 +02:00
Maurice d1dc8542ac fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:27 +02:00
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00
Maurice 99d7fb7f01 chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice c6a089107f chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice 626adead3f fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:20 +02:00
Maurice cd06f0f7f4 fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:19 +02:00
Maurice 44a12e21fc fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked costs are labelled in the trip currency (every linked
row, the link picker and the phone's linked costs), a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 10:36:03 +02:00
Maurice 6ed2561c63 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 10:36:03 +02:00
Maurice ae73527362 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.

On dev the trigger takes schema version 244, the slot it has on main, so a
4.3.3 install upgrading to 4.4.0 does not skip it. Web Push moves up to
245 and is idempotent, so a 4.3.3 install gets its table there. An
instance that already ran Web Push at 244 never runs 244 again, so 246
creates the trigger once more; everywhere else that is a no-op.
2026-09-26 10:36:03 +02:00
Maurice 8b6a8d3098 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 10:36:03 +02:00
Maurice 62f8b0deee fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 10:36:03 +02:00
Maurice 5d4178d067 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 10:36:02 +02:00
Maurice c5e405ca79 fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 27 locales.
2026-09-26 10:36:02 +02:00
Maurice c9dfe9c688 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.

Dropping the worker ends a Web Push subscription with it, so on such a device
the settings card shows push as off until it is switched on again. A missing
sw-push.js now fails the worker install with a 404 rather than index.html; the
comments that described the old answer say so.
2026-09-26 10:36:02 +02:00
Maurice 83d591dc6c fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 10:36:02 +02:00
Maurice a5745fd856 fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 10:36:02 +02:00
gso a0d89abde5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:02 +02:00
gso e6f66014a5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:01 +02:00
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00
Maurice 5f77c2cee9 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 04:22:50 +00:00
Maurice cf5920ebe0 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
2026-09-26 04:22:50 +00:00
Maurice a0952bd871 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 04:22:50 +00:00
Maurice 3c7f6e69dd fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 04:22:50 +00:00
Maurice 30a3f664b1 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 04:22:50 +00:00
Maurice 0a3a993dfc fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
2026-09-26 04:22:50 +00:00
Maurice bea3af4b45 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
2026-09-26 04:22:50 +00:00
Maurice 26ab4a58b1 fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 02:26:39 +00:00
Maurice 0887f251ce fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 02:26:39 +00:00
Maurice 287b84875b fix(budget): show the link controls on an explicit null check
targetId is a number or null; a bare && would render a 0 if it were ever 0 (Sonar S6439).
2026-09-25 16:25:30 +02:00
Maurice 8864edda18 feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could
not be tied to the booking it belongs to, and a booking held one expense.

- The Costs block of the booking, transport and place forms lists every
  linked expense with edit, unlink (the expense stays in Costs) and delete,
  and offers the expenses that belong nowhere yet under Link existing
  expense. The phone sheets get the same block in their own design, and
  their Files row lists the attached files and links an existing one.
- PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC)
  take reservation_id / place_id; create and update refuse an id from
  another trip on all three paths.
- The booking's mirrored price is the sum of its expenses when they share
  one currency (none counts as the trip's), else the first one, with that
  currency, and follows every change to a linked expense. The booking
  form's price field leaves a booking with several expenses alone.
- Deleting a booking deletes all of its linked expenses and broadcasts each
  one, also from the MCP delete tools.
- Cards show the price with its currency.
- Booking and transport dialogs: Booking Type and Travelers as dropdowns at
  the top, the URL beside Files (transports gain the URL field). The row is
  one shared component; linking and unlinking files go through the store.

Help center pictures and texts for the booking, transport and expense
guides are redone in all 27 languages, five Costs labels that were still
English in 16 languages are translated, and the wiki follows.
2026-09-25 15:34:32 +02:00
Maurice 1f79776070 feat(todo): To-Do in the look of the packing list, detail pane always in reach
Rows get the packing list's filled checkbox, priority, due date and list
as quiet badges, and the assignee as an avatar with the name as its
tooltip. The tasks sit in a card whose head names the filter and holds
the sort, Priority or Due date, which moved out of the sidebar; a second
click on the active one goes back to the manual order. The progress card
matches the packing list's.

The detail pane is a card with a box in its head that ticks the task
off, and Delete and Save changes stay in sight: the To-Do view now fills
the space under the Lists bar instead of a fixed height that pushed the
pane's foot off screen. The detail pane and the new-task form share one
set of fields, and a new list is named in a dialog, from the sidebar
and from the form; the dialog moved to shared/NameDialog for both lists.

The To-Do help pictures are re-rendered, the texts that described the
old sidebar and form follow in all 27 languages, and the wiki page is
updated. The phone screens are unchanged.
2026-09-25 13:29:06 +02:00
Maurice dc60f39554 feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
2026-09-25 12:57:15 +02:00
Maurice 5c312a8497 feat(packing): print, PDF and Markdown/CSV export of packing lists
The packing list bar gets an export menu next to the import button, both
now icon only. The menu prints the open view as a TREK styled page (trip,
dates, packed count, one card per category, bags), which the browser can
also save as a PDF, and downloads it as a Markdown checklist or as a CSV
in the exact format the import reads, bags included, so a list works as a
personal template without admin rights.

The import now also reads Markdown checklists: headings become
categories, "- [x]" marks an item packed, a "3x" prefix sets the
quantity and a "(200 g)" suffix the weight. The phone action sheet
offers the same exports through the shared usePackingExport hook.

The help center gets an export guide with new pictures, the import guide
and the wiki page describe the Markdown format, in all 27 languages.

Addresses discussions #1420 and #875.
2026-09-25 11:31:23 +02:00
Maurice 2a8071f642 feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.

The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.

Addresses discussion #2221.
2026-09-25 09:19:11 +02:00
Maurice 1727732841 feat(plugins): plugins can add POI categories to the map and answer them
A new manifest capability poiCategories (up to 4, icon from an allow-list,
strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the
hook:poi-category-provider permission. GET /api/plugin-pois in the plugin
contributions module answers them in the /api/maps/pois shape, with
search_plugin_pois as the MCP twin. plugin: ids no longer count as Google
place ids. SDK 1.8.0 carries the types, validation and docs.

On the map, one hook merges the built-in categories with the ones active
plugins declare, and the pill shows the plugin chips after a divider on
desktop and phone, scrolling sideways instead of shrinking a chip below
its touch target. A plugin chip searches through a repo that refuses
offline and parses the answer strictly. Its pins, on Leaflet and GL, use
the category's own icon and color, both checked again before they reach
any markup, and the hover card lists the plugin's details as escaped
text. A plugin POI can be added as a place like any other. The admin
panels on desktop and phone show a chip for the new permission and the
categories a plugin would add, from one shared module.

Addresses discussion #1781.
2026-09-25 01:24:55 +00:00
Maurice c7c9ba24a3 fix(journey): select all takes every photo in the range and imports keep their order
The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".

A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.

After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.

Addresses discussion #1587.
2026-09-24 23:48:49 +00:00
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00
Maurice 7ce31756a3 feat(settings): every date picker starts its week on the day you pick
Settings > General > Language & region gets "Week starts on" (Monday,
Sunday or Saturday), and Admin > User Defaults a default for everyone who
has not picked one. Monday stays the built-in value, so nothing changes
for anyone who leaves it alone.

Both calendar implementations follow it: CustomDatePicker, used by trip
dates, bookings, costs, to-dos and the admin school holidays on desktop
and phone, and the journey date picker, whose weekday headers were
hardcoded English and now speak the app language. The grid math and the
header row live in one helper (utils/calendarWeek.ts). The day names on
the options come from Intl, so only the label and a hint needed
translating. Vacay keeps its own per-plan week start.

The value is validated like the other regional settings: VALID_VALUES for
admin defaults, the display-settings MCP tool, and the managed key list.

Addresses discussion #2029.
2026-09-24 21:56:12 +02:00
pipipiper 196dc12762 feat(mobile): place details block in the place editor 2026-09-24 21:40:13 +02:00
Maurice bb5c5dcfc1 docs: TREK ships 27 languages now
Azerbaijani, Estonian, Slovak and Thai joined; the README and the wiki
still counted 23.
2026-09-24 17:35:25 +02:00
Maurice b7f777024a feat(i18n): bring the Thai translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers th
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:29 +02:00
Dan ba9936f90a feat(i18n): add Thai (th) translation 2026-09-24 17:34:29 +02:00
Maurice 3c036ea25d feat(i18n): bring the Slovak translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers sk
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:09 +02:00
Peter Gonda 80502dcc9d feat(i18n): add Slovak (sk) translation 2026-09-24 17:34:09 +02:00
Maurice ffa085aef7 feat(i18n): bring the Estonian translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers et
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:33:51 +02:00
Jaano Rosin 9263421c5e feat(i18n): add Estonian (et) translation 2026-09-24 17:33:51 +02:00
Maurice 33aa1cd52e feat(i18n): bring the Azerbaijani translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers az
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 16:57:11 +02:00
shakirgv 9b843bee80 feat(i18n): add Azerbaijani (az) translation 2026-09-24 16:57:00 +02:00
Maurice 8906b66bc8 feat(settings): set the Google Maps key through PLACES_API_KEY
The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
2026-09-24 16:50:02 +02:00
Maurice 79cd3095e0 feat(login): an OIDC-only instance shows the way to the IdP, not a form
On a slow line an OIDC-only login page drew the email and password form while it
asked the server which sign-in it offers, then the SSO button once it knew, and only
then left for the identity provider. Both looked like something to fill in or click.

Until the config answers, the page now shows a short wait instead of the form, and
on an OIDC-only instance it becomes a screen that only says where it is going,
"Taking you to Keycloak…", with nothing to fill in. A repeat visit announces that
redirect at once from the cached config. Should the redirect not have left the
page after eight seconds, a quiet link continues by hand.

The redirect still never runs on a cached config, right after a sign-out or after a
failed sign-in at the provider. That last case now loads the config as well, so an
OIDC-only instance shows its own screen with the error rather than a password form
nobody can use.
2026-09-24 16:34:51 +02:00
Maurice 20e912d6c4 fix(help): the document sync guide speaks for every store, not only Nextcloud
"Keep the documents in step with a Nextcloud folder" walked through Nextcloud
alone: its app password, its base folder, the question where the trip should live
in Nextcloud. The dialog offers five stores. The guide now reads for all of them:
how each files things (by tag, in a folder, in a space), what each signs in with
(an API token for Paperless-ngx, an API key and the organisation for Papra, username
and app password for Nextcloud, username and app token for OpenCloud, username,
password and a two-factor code for Synology Drive), that the base folder belongs to
Nextcloud and Synology Drive, and that the trip lives in a tag, folder or space. The
pictures keep Nextcloud as the example, and the text says so. All 23 languages use
the labels of their own interface.
2026-09-24 15:23:21 +02:00
Maurice 3b05a59b19 fix(help): the days guide shows deleting a day and adding one with a date
"Add, reorder and rename days" still described the reorder popup as it was before
#2485: Move up, Move down and one Add day at the end, and its picture showed that
popup. The guide has five steps now. The reorder step names the bin on each day and
the two ways of adding one, the next date or Without date, and a new step shows the
question before a day goes, with what goes with it, answered with Cancel. The first
step says that shortening the dates lists the days that go, and the tip says moving
days can be undone and deleting one cannot. All 23 languages use the labels their
own interface shows.

The picture script looked for the Add day button that #2485 removed, so it could not
run any more. It frames the popup itself now, asks about the second day so the
question has places and a booking to list, and clicks the first day a second time
when the steps before left it selected and the first click folded its panel away.
2026-09-24 15:00:30 +02:00
Maurice 055c3ae5c7 feat(help): readable rings, real documents and imagery in the pictures
The pictures picked up five faults that the run could not see, because a
passing step only proves the click landed.

The ring vanished on the lightbox: the accent is nearly black and so is
the overlay. It now measures the surface behind it and puts a white
hairline outside itself and the number only where the contrast is too
low, so every other picture keeps the single line it had.

The number fell outside the picture whenever the target was wider than
the frame, which is every full-width row: the frame is known before the
ring is drawn, so the number moves inside far enough to clear the edge.

The satellite imagery was a grey rectangle although every tile came back
200. Leaflet fades a tile in over 200 ms and takes the progress from
`+new Date()`, and the browser's clock is pinned to the picture day, so
the opacity it writes on every frame is always 0. A stylesheet on
Leaflet's own `leaflet-tile-loaded` outranks that inline style. The
satellite guide also waits for decoded tiles now instead of for a tile
element to exist.

The uploaded files looked like nothing anyone would upload. A PDF is
drawn with an issuer band, a reference, a table of fields and a footer,
and the two photographs are gone: a drawn photograph reads as clip art
at any size, so the trip carries a map screenshot with a pin and a
booking confirmation in a browser window, which are graphics in reality
too.

In the panel, a step picture was covered into a 16:10 box and lost its
sides; contained everywhere now, the hero and the result in 16:9.

Beside that: the place guide gained the drag-and-drop route to a day as
its fourth step, in all 23 languages, and `e2e/help/README.md` now holds
the whole visual contract, the traps that cost this round (the pinned
clock, prettier against the client sources, a killed run holding its
ports) and how to find out which pictures a change forces to be retaken.
2026-09-24 14:06:38 +02:00
Maurice 8aa9e308bb feat(help): every picture from one run
The whole set, 914 files, made in one pass on one machine against one
calendar: 41 screens, 225 guides, 764 steps. What changed beyond the new
guides: the pictures carry the run day's dates now, the Files tab's
preview shows a real PDF page, the Place details card has its hours, its
ring and its phone number, the trip map and the Atlas carry the Dawarich
control the new bullets describe, and the Files toolbar has Document
sync beside Trash.
2026-09-24 14:06:38 +02:00
Maurice c4d436bfc1 feat(help): translate the new guides into all locales
Ninety-eight keys in the other 22 languages: the nine guides of this
round, the reworked read-place and import-booking-file, and the two
screen bullets. Every UI label in them is the app's own string in that
language, and where a label is still English in a locale, the text keeps
it English so the reader finds it on screen.
2026-09-24 14:06:37 +02:00
Maurice 41851429c9 feat(help): guides for what TREK talks to, and a seed that follows the day
The first round could not photograph a few screens because the service
behind them was not there: Dawarich, AirTrail, a document store, the
booking extractor. They are there now, so the guides are too.

- map-dawarich-trail, dawarich-countries and dawarich-wishes: the run
  uploads a recording of the seeded trip, and of the year before it, into
  a Dawarich of its own (dawarich-track.ts) and reads it back.
- airtrail-import: four flights are put into an AirTrail account, two of
  them connecting in Tokyo, so the picker offers the layover.
- import-booking-file and import-transport-file: the confirmations carry
  their facts as schema.org data the way portals send them, the extractor
  reads them, and the guides go on to the filled-in form and the card.
- files-sync and document-providers: a Nextcloud folder with two
  documents in it, bound to the trip, and the switches in the admin panel.
- read-place: three seeded places carry their OpenStreetMap id, so the
  card shows the week's hours, the phone number and the Open or Closed
  ring. The rating stays Google's, and the texts say so now.
- map-compass on the MapLibre renderer, which the guide switches to and
  back; files-preview opens a PDF, which needs the full browser rather
  than the headless shell (channel: chromium on the help-media project).

Each of those takes an address and a key from e2e/help/media.env, which
is not committed; media.env.example and the README say what each one
needs and why the address has to be the machine's own on the network.

The seed's dates are offsets from the day the pictures are taken
(e2e/dates.ts): the trip ends today, so the boarding pass, What's Next
and the forecast have something to show whenever the run happens, and
nobody bumps a date in three files every few weeks. run.mjs fixes the day
once per run so the projects and the workers agree across midnight.
2026-09-24 14:06:37 +02:00
Maurice b1528b63c4 docs(wiki): a link can be edited now
The Links section still described the panel as it was before #2414: pin
or delete, and add again to correct a typo. It names Edit link now, says
the form takes only http and https addresses and what the chip shows, and
it is a panel beside Notes rather than a tab, except on the phone.
2026-09-24 14:06:36 +02:00
Maurice 33b65ab8fa fix(i18n): two strings the locales had wrong
shared.other labels the same fallback group on the public share page that
the app itself calls Sonstiges and Otros, so de and es said one thing in
the trip and another beside it. admin.tabs.addons was Russian in uk.
2026-09-24 14:06:10 +02:00
Maurice b7dcb58a6b chore(i18n): drop the catalogue entries nothing renders
reservations.fromPlan, packing.suggestions, packing.suggestionsTitle,
packing.allSuggested, packing.emptyHint and the packing.suggestions.items
array are referenced by no component; the suggestions block left the
packing list in #14 and the keys stayed behind in all 23 languages. The
VORSCHLAEGE constant beside them was exported and imported nowhere.

With the one structured entry gone, a catalogue value is a string and
nothing else, so TranslationValue says so and the placeholder spec no
longer needs to skip what is not one.
2026-09-24 14:05:46 +02:00
Maurice 0f28c929be fix(files): the image lightbox renders through a portal
Rendered in place, its fixed overlay sat inside the trip page's stacking
context, below the navbar's z-[200]: the bar painted over the lightbox's
header, so Open in new tab, Download and the x were unreachable. The two
document previews beside it already portal into the body for this reason.

The tests read the lightbox through baseElement now, since a portal puts
it outside the container Testing Library returns.
2026-09-24 14:05:46 +02:00
Maurice e8637f7208 fix(help): the leg mode lands when the menu closes, not when one icon changes 2026-09-24 14:05:46 +02:00
Maurice dbc369beee fix(help): the kind disc answers to its own label, and so does the row around it 2026-09-24 14:05:46 +02:00
Maurice 168cd742a4 fix(help): three the third full run showed up
- The lightbox header's button group is the right thing to ring, but its centre
  falls between the buttons where the picture underneath takes the pointer, so
  the hover never landed. The pointer goes on a button, the ring stays on the
  group.
- The collection the place is saved into was matched by a name another guide's
  list starts with.
- help-media gets four minutes a guide instead of two and a half. A guide is a
  whole task and a few of them wait on the network more than once; the
  nearby-places one runs two POI searches and opens the place form on top.
2026-09-24 14:05:46 +02:00
Maurice 05b453b493 fix(help): the collection helper belongs beside the scripts, not inside them 2026-09-24 14:05:46 +02:00
Maurice 13017b505a fix(help): the undo guide needs the day it puts a place on to stay open
The + at the end of a place row belongs to the open day, and closing the day's
details panel closes the day with it, so there was no + to click and the guide
waited for one until the test gave up.
2026-09-24 14:05:45 +02:00
Maurice f6b2880abb fix(help): three more the whole suite showed up
- place-to-collection found the place already in a list, because the Collections
  screen's guides run earlier and leave it there. It clears the place first now,
  with the same helper its cleanup uses.
- The road trip rail leaves out a day with nothing to drive, which its own
  helper says, and the fold step was the last place still pointing at day 1.
- The transport form's location search took whatever the geocoder ranked first,
  and for a landmark that is as often a shop down the road. It picks the
  suggestion that answers the query.
2026-09-24 14:05:45 +02:00
Maurice 64b190862e fix(help): the locators the whole suite in one database showed up
Every one of these passed when its own file was run alone and failed when all
of them ran against one seeded trip, because another screen's fixture had put
something in the way.

- The Bookings fixture names a booking after a place, so the Assign File dialog
  offered two entries opening with the same words.
- The Lists tab has a Shared button, which the loose name of the navbar's Share
  also matches, so opening a trip on that tab never settled.
- A traveller's avatar is named by the initial it draws, not by the username;
  the title carries the name.
- Departure and Arrival are what a booking gets when it crosses days, so the
  guide about them points at the flight, not at the train that starts and ends
  on one day.
- The What's Next stop was timed from the wall clock of whoever runs this,
  while the browser's clock is pinned to the day the pictures are taken on.
  Both now come from the same constant.
- The map was panned with the arrow keys, which Leaflet only reads while its
  container has focus, and focusing it from here did not take. It is dragged
  now, from a point checked to be free of markers, because a press on a marker
  drags the place onto a day instead.
2026-09-24 14:05:45 +02:00
Maurice e9393e9e6c fix(help): a duplicated trip outlived its guide and broke another screen
The duplicate-trip guide dropped its copy by position, keeping whatever the
trips endpoint happened to answer first. The copy survived, and three files
later the journey dialog offered two entries starting "Weekend in Lisbon" and
the create-journey guide could not say which one it meant.

The copy is now dropped by name and the delete is checked, and the journey
dialog picks its trip by accessible name rather than by a substring another
trip shares. Each file passed on its own before this; only the whole suite in
one run showed it.
2026-09-24 14:05:45 +02:00
Maurice d2e352085d fix(help): the corrected sentences in the other languages too
Forty-five values were rewritten after a second pass over the running app found
the old text naming a control that is not there or describing one in the wrong
order. Each locale's existing translation was edited rather than replaced, so
the register and the vocabulary of the block it sits in are kept.
2026-09-24 14:05:45 +02:00
Maurice 821a103146 feat(help): translate the Road Trip block, and parity is green again
All ten screens of a trip are now in every language TREK speaks: 2134 help keys
in each of the 23 locales. i18n:parity:strict passes for the first time since
the help center landed, which also un-skips every CI job that was waiting
behind it.
2026-09-24 14:05:45 +02:00
Maurice 3c805e2c52 feat(help): translate the Costs and Transports blocks into all locales 2026-09-24 14:05:44 +02:00
Maurice e7db35b0a8 feat(help): translate the Lists and Bookings blocks into all locales 2026-09-24 14:05:44 +02:00
Maurice 1a5a8a9372 feat(help): translate the Collab block into all locales 2026-09-24 14:05:44 +02:00
Maurice 0ed0d98c32 feat(help): ten more screens of a trip, registered with their guide scripts
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides over 360 steps, each one a task a reader can
follow, with the Playwright script that will photograph it. The fixtures they
need go in beside ensureDaysFixtures, and every screen is a child of the trip,
so the panel folds them under it.

The capture now pins the browser's clock to the day the seeded trip ends. A
picture has to look the same whoever takes it and whenever: a day later the
trip is over, My Trips moves it out of the boarding pass, What's Next has
nothing left to list, and a forecast for a day in the past is not a forecast.
Only `Date` is pinned, so timers keep running.

trip-days is green end to end after its locators were checked against the
running app; its route guides read day 6, whose two stops sit next to each
other, because day 1's are separated by the flight and have no leg of their own.
2026-09-24 14:05:44 +02:00
Maurice 30a7cfd483 feat(help): the English texts for the ten screens of a trip, and four of them translated
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides and 360 steps, written against the running app
rather than against the components, so every label a guide names is a string the
reader will actually see. Places, Day details, Map and Files are translated into
the other 22 languages; the rest follow.

Also reworded the Settings overview: it called TREK self-hosted, which the
repository's own wording rule forbids and which nothing was running to catch,
because the test that does needs i18n parity and parity has been red.
2026-09-24 14:05:43 +02:00
Maurice cb6aeb7bfd test(members): stop the location stub from swallowing the request it waits for
Two cases replaced window.location wholesale to spy on reload. The copy is flat,
the real object's fields are getters on its prototype, so the replacement has no
href for a relative request URL to resolve against and the DELETE the leave-trip
case waits for never reached the mock server. It also stayed replaced for the
rest of the file, which took six later cases down with it.

The leave-trip case asserts the request, so it keeps the real location: jsdom's
reload only logs. The transfer case asserts the reload, so it keeps the stub,
and the original is put back after every test.

Nobody saw this: the client coverage job needs i18n parity, which has been red.
2026-09-24 14:05:43 +02:00
Maurice d3a9e00752 test(help): find the fold toggle inside its own row in the switcher
'2 sub-screens' stopped identifying one button the moment a second screen had
two of them, and the trip is about to have a dozen. The toggle is looked up
within the row it belongs to instead, which says what the assertion means.
2026-09-24 14:05:43 +02:00
Maurice 1f01447093 fix(collab): the expand button on a note showed its own translation key
`collab.notes.expand` was never added to the catalogue, and `t()` answers with
the key itself, so the `|| 'Expand'` fallback never ran and the tooltip read
collab.notes.expand. `common.expand` says the same thing and is already
translated everywhere, so the button uses that, and it now carries the label
for assistive tech as well.
2026-09-24 14:05:43 +02:00
Maurice 1e9b23c5dd fix(help): the corrected places texts in the other languages too 2026-09-24 14:05:43 +02:00
Maurice 72b32c6a12 docs(wiki): a page for the help center
What the ? in the navbar opens, what an overview and a guide contain, how the
search and the screen switcher work, where the pictures come from and what
happens on a screen that has no help yet. Linked from the sidebar, from the
features list and from In-App-Help, which documents the other thing and was
being confused with it.
2026-09-24 14:05:42 +02:00
Maurice 431eb883c9 feat(help): pictures and translations for the trip's places column
All eight guides of Trip > Places now have their pictures, taken from the real
app, plus the hero of the screen, and the block is translated into the other
22 languages.

Making the pictures showed three texts describing a UI that is not there, and
they are corrected: the file import dialog has no Enrich places via Google
switch (only the list import has one, and only with a Google key), a GPX import
closes with a message while only KML and KMZ leave a summary behind, and both
list providers are always offered. The search and the details column need no
Google key either: they fill themselves from the TREK index, OpenStreetMap,
Wikipedia, Wikivoyage and Wikimedia, and only ratings are Google's.

The script needed the same honesty. ConfirmDialog and the two import dialogs
are portals of their own with none of Modal's classes, so they get their own
locators, and create-place now looks up a place whose entry actually carries a
picture, opening hours and a description, instead of photographing an empty
details column.
2026-09-24 14:05:42 +02:00
Maurice 5584d287a9 refactor(help): register a screen from one description, and fix the doc links
Every context file carried its own copy of the same little guide factory and
the same context literal. Thirteen screens of a trip would have made thirteen
copies of it, which is both the duplication a quality gate counts and the
hand-mirrored state the repository asks us not to keep, so the shape moves into
defineScreen and a context file becomes data: ids, counts, docs, cross-links.

Four doc links never resolved. Trip-Planner and Sharing-and-Collaboration are
not wiki pages (Trip-Planner-Overview and Trip-Members-and-Sharing are), the
places import anchor is importing-multiple-places, and the offline anchor was
written in GitHub's slug, which is not the one the in-app reader produces.
Nothing caught them, so the registry test now checks every slug and every
anchor against wiki/, with the heading slug itself lifted out of HelpPage so
the reader and the test cannot drift apart.
2026-09-24 14:05:42 +02:00
Maurice cd0a80e70d feat(help): translate the trip block into all locales 2026-09-24 14:05:42 +02:00
Maurice 4346d7f2c1 feat(help): in-app help center with per-screen guides and pictures
A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
2026-09-24 14:05:42 +02:00
github-actions[bot] 151e32107f chore: bump version to 4.3.2 [skip ci] 2026-09-24 11:14:02 +00:00
Maurice 8779529efb feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.

Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
2026-09-24 11:32:52 +02:00
Maurice e1315806c2 feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.

It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.

The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.

The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
2026-09-24 11:32:52 +02:00
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00
Maurice 3062df153f chore(notices): the release notice points at 4.3.2 2026-09-24 11:27:12 +02:00
Maurice 233076030b fix(docsync): a store picked before the panel settles stays picked
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
2026-09-24 08:14:29 +02:00
Maurice c5ac479915 fix(dayplan): sort the trip's dates with an explicit compare
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
2026-09-23 23:11:10 +02:00
Maurice 954758082b fix(dayplan): calmer day dialogs, and the shrink question side by side
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
2026-09-23 22:40:47 +02:00
Maurice 83be759c49 fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
2026-09-23 22:40:47 +02:00
Maurice f3ca0b1a4c test(mobile): the delete-day confirm test takes a free id
FE-MOB-SHOST-030 is taken by the invite field test that landed on dev in
the meantime, so the delete-day confirm test moves to 032.
2026-09-23 22:40:47 +02:00
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00
Maurice 1639fc6a3e docs(wiki): deleting and adding days, and what shortening a trip removes
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
2026-09-23 22:40:46 +02:00
Maurice 1b63285ad0 feat(trips): shortening a trip says first which days go and what is on them
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
2026-09-23 22:40:46 +02:00
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00
Maurice 436f0740fb fix(planner): the delete question says plainly what goes with a booked place
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
2026-09-23 22:39:54 +02:00
Maurice 1423ba8933 fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
2026-09-23 22:35:01 +02:00
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00
Maurice 362b1ee8d1 fix(map): a selected stay is flown to even when no pin stands for it
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
2026-09-23 22:11:51 +02:00
Maurice b5457d9e6a fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
2026-09-23 21:28:34 +02:00
Maurice a1d6348c5f revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
2026-09-23 20:32:41 +02:00
Maurice f16698bdb1 fix: an Immich error leaves out Node's advice, a locked save looks locked
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
2026-09-23 18:03:58 +02:00
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00
Maurice 0df82185bd feat(collections): a saved place keeps its price, website and phone, editable like in a trip
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
2026-09-23 17:41:26 +02:00
Maurice e8ec3427e5 fix(share): the user picked to invite shows in the field before it is sent
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
2026-09-23 17:41:26 +02:00
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00
Maurice 41b14ebc2a fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
2026-09-23 17:41:25 +02:00
Maurice 05f533dd0d fix(roadtrip): a late check-out morning says so, a road driven twice takes its via
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
2026-09-23 16:01:40 +02:00
Maurice e6e520b78f fix(roadtrip): the hotel at the edge of a day stands, drives and reads right
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
2026-09-23 15:31:09 +02:00
Maurice 05651eed1a docs(wiki): a road trip day can start and end at the stay
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
2026-09-23 15:31:09 +02:00
Maurice 7e7d13c021 feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
2026-09-23 15:31:09 +02:00
Maurice 514e2e62ee feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
2026-09-23 15:31:09 +02:00
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00
Maurice 3a67a8e279 fix(planner): trip places in every manual location field, never at 0,0
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
2026-09-23 13:41:31 +02:00
hiro-nikaitou cc5957dc6a fix(planner): offer trip places in manual transport
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
2026-09-23 13:34:02 +02:00
Maurice 83d088e842 fix(roadtrip): a merged drive keeps its first piece ends when a leg comes without them
A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
2026-09-23 12:53:24 +02:00
Maurice e921799580 test(roadtrip): mount the phone case of a refused way the way the phone suites do
The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
2026-09-23 12:30:10 +02:00
Maurice 34c1dc79b5 fix(roadtrip): a drive bent through a via prints its whole length, and a refusal is said once
A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.

On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
2026-09-23 12:03:00 +02:00
Maurice 451e6d240b fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken
The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
2026-09-23 11:28:24 +02:00
Maurice ab3a81a9eb fix(roadtrip): a booked ferry is driven to, and one without a date says so
A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
2026-09-23 11:28:24 +02:00
Maurice e6daed9403 fix(roadtrip): the drive between two days can take another way too
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
2026-09-23 11:28:24 +02:00
Maurice 53a422d22d fix(roadtrip): other ways come from the router that draws the leg, and a choice is saved once it holds
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.

The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
2026-09-23 11:28:24 +02:00
Maurice 6124e0b056 fix(maps): amap hours are read whole or not at all, and the tip stash lives on the service
The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.

amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.

The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
2026-09-23 09:36:04 +02:00
pipipiper d52154c765 fix(maps): amap answers for tip-only places, region-bearing fallback queries, and Chinese hours 2026-09-23 09:36:04 +02:00
Maurice aceb75949b fix(planner): closing the place dialog resets the whole search block
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.

Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
2026-09-23 09:11:48 +02:00
Luffy 4f5a6bb7d8 fix(planner): clear place search input when modal closes 2026-09-23 09:07:48 +02:00
Maurice d609e1e202 Merge pull request #2466 from liketrek/docs/wiki-mcp-openwebui-callback
docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
2026-09-23 08:49:56 +02:00
Maurice 6f3977e4c1 docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
The MCP setup page gave Open WebUI's redirect URI as
{your Open WebUI URL}/oauth/clients/{connection id}/callback. Open WebUI puts
mcp: in front of the ID it builds that path from, so a client registered by
hand with the bare ID never matched and the consent step answered
"Unregistered redirect_uri". The page now names the real path and adds how
Open WebUI connects: it registers and trades the code from its own server, so
its container has to reach the public TREK address, and its WebUI URL has to
be the public https address.
2026-09-23 08:28:29 +02:00
Maurice 615e74aff6 fix(pwa): an upgrade counts as applied once the new build has taken over
After a server upgrade the app wrote the new version into its marker
before the service worker had fetched the new build. When that fetch
failed, when the new precache broke off halfway, or when the browser had
already swapped workers before the version check answered, the page kept
the old bundle, and every later launch took the marker at its word and
never tried again. An installed PWA went on showing the previous UI.

The marker now moves only once the handover is done or turned out to be
unnecessary, so a failed attempt is simply retried on the next launch. A
failure no longer reloads into the shell the old worker serves again, a
worker that took over before the check answered still gets its reload,
and no path reloads more than once per version and session. Caches and
workers are still never deleted.
2026-09-22 21:48:23 +02:00
Maurice bb515ac3cc fix(planner): booking codes stay blurred in the edit forms too
With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.

One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
2026-09-22 21:48:22 +02:00
Maurice 93dddaa4af fix(mobile): booking routes on the phone map follow the day they run on
A booking switched on with "On map" stayed drawn on every day of the
phone's plan map, so a flight from the first day crossed the map of the
third, although a day chip frames one day and drops the other days' pins.
The plan map now draws such a booking only on the days it runs on, the
arrival day of an overnight one included, and the all-days view still
shows every booking that is switched on. The switch itself stays
trip-wide, and the desktop, collections and the road trip stage draw
exactly as before.

"On map" in a booking's sheet now reads as on only where the plan map
draws the route. From any other day a tap takes the map to the booking's
day, switching the route on when it is off, instead of hiding a route
that is not drawn there anyway.

The phone settings also gain the "Always show booking routes" switch the
desktop already had. As on the desktop it sets the default for trips not
yet touched on this device, so a trip where a route was switched on or
off by hand keeps that choice.
2026-09-22 21:48:22 +02:00
Maurice 1a8d5a3dd9 fix(roadtrip): a stop with a start is driven where the day list shows it
The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.

Behind a later start the road trip reached such a stop late, and the
night spill took its earlier clock for a midnight: the stop and every
stop after it opened the next day's card, and a day left with one stop
fell to the placeholders at the bottom. A stop the schedule marks late
now stays on the day the schedule files it on, and the stops behind it
are read from its hour, so a clock that goes back past that hour still
starts the next day. The desktop rail, the phone's road trip tab and
the MCP calculate_roadtrip tool share that spill.
2026-09-22 21:48:22 +02:00
github-actions[bot] ed8acf4c4b chore: bump version to 4.3.1 [skip ci] 2026-09-22 12:17:29 +00:00
Maurice a280517ff3 Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
2026-09-22 13:30:38 +02:00
Maurice 2dd77aaac1 docs(wiki): the pages the 4.3.1 changes left behind
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
2026-09-22 13:30:12 +02:00
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00
Maurice cb85ba915c fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
2026-09-22 13:30:12 +02:00
Maurice 81292d4d2b fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
2026-09-22 13:30:12 +02:00
Maurice 757967bf18 fix(map): the location button follows the viewport instead of the mount
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
2026-09-22 12:57:04 +02:00
Maurice b0c19bfac1 fix(settings): the client registration form says what the server refused
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
2026-09-22 12:12:41 +02:00
Maurice da32eb8a3c fix(mcp): answer the discovery documents under the server address too
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
2026-09-22 12:12:41 +02:00
Maurice a44df6cf11 fix(planner): place edit and delete controls follow place_edit, not day_edit
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.

Closes #2446
2026-09-22 11:29:43 +02:00
Maurice e510ff25ab fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false. The nested objects a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
2026-09-22 11:06:13 +02:00
Maurice 34f3aeee5f fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 11:06:13 +02:00
Maurice bdd326163f fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 10:32:08 +02:00
Maurice b1baf3baa9 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 10:32:08 +02:00
Maurice fdab3bb347 Merge pull request #2437 from liketrek/fix/mcp-search-provider-test
test(mcp): search_place hands the provider option on
2026-09-21 23:05:25 +02:00
Maurice d288459449 test(mcp): search_place hands the provider option on, and refuses any but Google
The tool passes `{ googleOnly }` to the search since #2436; four assertions
still expected the four-argument call. Pinned the new argument, and the one
case the contract added: `provider: 'google'` reaches the search as
`googleOnly: true`, any other value is refused by the schema before the
search is asked.
2026-09-21 23:04:26 +02:00
Maurice e35d95da71 Merge pull request #2436 from liketrek/feat/places-google-only
feat(places): send a search to Google, once or always
2026-09-21 22:23:59 +02:00
Maurice 6b1d65b06c fix(i18n): the Google-only strings carried a doubled escape in English 2026-09-21 22:21:02 +02:00
Maurice b6fcaced2e feat(planner): the places filter is one select, not four pills
With the tracks tab the pill row had grown past what the rail can hold: four
pills sharing one line clipped a word as soon as the panel narrowed. The
filter is the app's own select now, the count riding on the trigger the way
it rode on the active pill, and every option carrying its own in the menu.
2026-09-21 22:18:51 +02:00
Maurice e12ae950df feat(places): send a search to Google, once or always
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
2026-09-21 22:14:44 +02:00
Maurice 11b1399b5c fix(pdf): list a day the way the plan lists it, without the booked night's stop
Booking a night writes the hotel onto its check-in day as a stop, and since
4.3.1 that stop heads its day. The day plan hides it and shows the booking as
the day's accommodation block, but the desktop toolbar hands the export the
store's own assignments, filter and all, so the PDF printed the hotel place
above a flight that left in the morning.

The export now drops that stop before it merges the day, as the list does. The
mobile export already received the filtered set and was right all along.

Closes #2434
2026-09-21 20:54:23 +02:00
Maurice 0644106f7a fix(collections): a marker click shows the place, every time
A marker click toggled the selection, so the second click on the marker of the
place that was open closed it again, which read as the details refusing to
open: every other click on the same marker showed nothing. A marker click now
selects and never clears; the map background and the close control do that,
and a list row still toggles as before.

Closes #2431
2026-09-21 19:35:41 +02:00
Maurice 903378f8ad fix(maps): build the day's road from the stops the day plan shows
Booking a night writes the hotel onto its check-in day as a stop, for the road
trip's driving chain. The day plan hides that stop and draws the hotel as the
day's bookends instead, but the route builder behind the map line and the
phone's travel connectors still read it from the store. It carries no time of
its own, and a timed booking is seated behind the last timed stop, so on a day
that opened with a flight the hidden stop led the day: the map drove from the
hotel to the departure airport and back to the hotel after landing.

The builder now leaves the booked night's stop out, as the list does, and the
bookend rules put the hotel where it belongs: behind the flight, on the road
from the arrival airport. A hotel the traveller placed as a stop of their own
is untouched.

Closes #2430
2026-09-21 18:09:44 +02:00
Maurice 84d3dd0c4a fix(roadtrip): a hop between two desks keeps its line and drops the pill
The hire desk beside the terminal is a 200 m leg, and the pill under it read
"0 km in 0 min" where the eye wants the next stop. Under 150 m and two
minutes a leg is a hop: the rail keeps the dashed line, the phone chain skips
the row, and neither offers other ways of driving it. A note a plugin
attached keeps the row.

Refs #2428
2026-09-21 17:16:19 +02:00
Maurice bdb031a1e9 feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
2026-09-21 17:05:42 +02:00
Maurice 0b4230ff4c fix(roadtrip): the booking a terminal opens has somewhere to open
The day panel owns the transport detail dialog, and in road trip mode the
day panel is not mounted: a terminal row, a ride pill and a map endpoint all
set the booking to show and nothing showed it. The rail brings the dialog
along now. Also drops a translation key nothing read.
2026-09-21 16:17:49 +02:00
Maurice fa35966ab8 feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals
The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.

A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.

The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.

Closes #2428
2026-09-21 16:03:21 +02:00
Maurice 4be2314856 docs(wiki): name the toolchain npm asks for on install since better-sqlite3 13 2026-09-21 11:09:38 +02:00
Maurice f43b89be13 test(journey): the editor tile for a clip with and without its poster 2026-09-21 11:08:38 +02:00
Maurice ac23773352 fix(journey): the map waits for its first view before it pans to the active entry
The journey map sets its first view on a rAF after the build, and the active
entry asks for its pan on a 50 ms timer. In a background tab, or on a slow
frame, the timer wins. Leaflet does not refuse a pan on a viewless map, it
takes it as the first view with the zoom still undefined: the tile layer aborts
its own add on that, the load event stops halfway, and every entry marker
queued behind it never reaches the map. The catch that was meant for this
never fired, because nothing threw. The next rebuild, after any save on the
page, then tore down markers that were never added and died in Leaflet's icon
removal, and the error boundary took the whole journey page with it.

Both pans go through whenReady now: at once on a map with a view, otherwise
right after the fit lands.
2026-09-21 11:02:01 +02:00
Maurice 8138c83558 fix(journey): the video player brings its own icons instead of asking cdn.plyr.io
Plyr's defaults point at its CDN twice: the icon sprite for the controls is
fetched over XHR, and on teardown the element is pointed at a blank clip there
to abort the stream. connect-src and media-src both refuse that host, so the
clip played but every button in the player was blank, and each close of the
lightbox logged a blocked request and an uncaught XHR error.

The sprite ships with the bundle now, served from this origin, which Plyr then
references directly instead of loading it. plyr's exports field hides the file,
so it goes through the same resolver alias the rtl-text plugin already needed.
The teardown source is empty: that aborts the stream just the same, and the
element is unmounted in the same tick.

Refs #2341
2026-09-21 11:02:01 +02:00
Maurice 141afe708d fix(journey): let the browser read a picked clip, so it previews and gets a poster
A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.

media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.

Closes #2341
2026-09-21 10:35:37 +02:00
Maurice 43a3a5217e fix(server): better-sqlite3 13, so the image never compiles it against Node headers
The runtime stage installed python3 and build-essential for one reason: when
better-sqlite3 12.x could not download its prebuilt binary, node-gyp built the
addon against the headers of whatever Node the base image carried that day.
Since Node 24.19 those headers give node::ObjectWrap a cleanup hook that the
runtime cannot unregister, and the first garbage-collected statement takes the
process down with "Assertion failed: (env) != nullptr". The published image
was spared only because its prebuild download had succeeded (issue #2421).

13.x is built on N-API, so it never touches that path, and it ships the binaries
for amd64 and arm64 inside the npm tarball. Nothing is compiled any more, and
the toolchain leaves the Dockerfile. The install runs with --ignore-scripts:
a lockfile-driven npm ci still synthesises `node-gyp rebuild` for the
binding.gyp it finds and ignores the package's gypfile:false (npm/cli#9837),
which without the toolchain would fail the build.

Closes #2421
2026-09-21 10:35:27 +02:00
Maurice d14d83ebaa fix(dashboard): a long trip title cannot widen the hero card
The hero's content grid had an auto column, which takes the title's
min-content width. A title that is one long word made the column, the
tools row and the boarding pass as wide as that word, with edit, clone,
archive and delete pushed off the card. The column is capped at the card
now, and the title breaks inside it and stops at two lines.
2026-09-20 23:08:41 +02:00
Maurice cce64c4030 fix(oidc): a successful SSO login writes the user.login audit row
Every other login method writes one; the SSO callback touched last_login and
login_count and wrote nothing, so an instance moving its users to SSO watched
its audit log go quiet while looking complete. The row is written in the
callback, where the provider has vouched for the user and the client IP is,
next to the role-change row, with method: oidc in the details the way the
passkey login names its method.

Closes #2417
2026-09-20 22:28:35 +02:00
Maurice bf0507ddb5 fix(planner): Google hours that share a half of the day convert whole to 24h
Google writes a range that stays in one half of the day with the meridiem on
the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon.
The 24-hour conversion only saw times carrying their own AM/PM, so the
opener stayed "1:00" beside a converted "15:00". The closing meridiem is
handed back to an opener that has none before converting; a range spelled
on both sides, the 12h display and lines without a meridiem are untouched.

Closes #2412
2026-09-20 20:28:23 +02:00
Maurice 9bd9ff4a80 style(collab): a link chip's title takes its width first, the host gets the rest 2026-09-20 20:11:10 +02:00
Maurice 8845f73eb9 style(collab): the host gives way before the title does, all of it 2026-09-20 20:09:22 +02:00
Maurice 6b09a8bc11 style(collab): the title of a link chip gives way last
Two to a row, the title and the host were shrinking together into two
ellipses. The host shrinks first now, the open glyph takes the favicon's
place on hover instead of room of its own, and the actions are a touch
smaller.
2026-09-20 20:07:39 +02:00
Maurice 67dff455da style(collab): link chips two to a row across the panel
A chip takes half the panel now, one to a row where the panel is narrower
than a container query's 420px; the title and the host give way inside it
instead of capping their width.
2026-09-20 20:04:39 +02:00
Maurice 97a2ea1748 style(collab): the chip actions keep their width
Unfolding them reflowed the row under the pointer, and a chip that grows as
you reach for it is a chip you cannot catch. They sit faint and come forward
on hover instead.
2026-09-20 19:52:40 +02:00
Maurice 8d70d875b3 style(collab): shared links as chips
One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
2026-09-20 19:50:06 +02:00
Maurice 3dfdc92b2a fix(collab): a shared link can be edited, and its glyph opens it
A link in the Collab tab could only be deleted and added again once its
title or address needed a correction; the update route has taken both all
along. The add dialog edits a link handed to it now, behind a pencil on
each row. The external-link glyph next to a row that was already a link
read as a button doing nothing; it is a link to the address itself now.

Closes #2414
2026-09-20 19:43:52 +02:00
Maurice 84b73d1a83 fix(planner): the last day scrolls into view beside the road trip switch
With the road trip addon on, the desktop panel puts the Days / Road trip
switch above the day list. The list was sized by height alone, so it ran the
switch's height past the panel's clipped edge and the last day could never
be scrolled into view. It is a flex child of the panel now and yields that
height; where nothing sits above it, the height still fills the panel.
2026-09-20 19:34:47 +02:00
Maurice eeda3188c2 Merge pull request #2411 from liketrek/fix/roadtrip-night-reseat-migration
fix(roadtrip): seat the nights booked before this release where their check-in says
2026-09-20 19:28:33 +02:00
Maurice be9a38e3b0 fix(accommodations): a second night on the day counts by its check-in
Two bookings on one day used to settle by whichever was seated last, because
the other night's stop carries no hour of its own. Its check-in counts now,
in the service and in the migration alike.
2026-09-20 18:48:26 +02:00
Maurice 417d0445b9 fix(roadtrip): seat the nights booked before this release where their check-in says
A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.

A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
2026-09-20 18:45:37 +02:00
Maurice a86e4b560a test(accommodations): a quiet edit still corrects a night the clocks contradict
Covers seatedByCheckIn with a check-in set, which the re-seat on a new
check-in no longer passes through, so the accommodations function ratchet
held.
2026-09-20 17:49:54 +02:00
Maurice cd769d9993 fix(roadtrip): a booked night holds its check-in and heads its day
A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.

The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.

A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.

The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
2026-09-20 17:26:50 +02:00
Maurice 979a9283cd Merge pull request #2408 from liketrek/fix/431-collection
fix(notices): keep the promise box whole when the release notice has to scroll
2026-09-20 16:56:10 +02:00
Maurice 32f2488774 fix(notices): keep the promise box whole when the note has to scroll
On a 1080p screen at 100% the release notice's right column runs out of
height. The column scrolls, but the promise box is a flex item with
overflow hidden, so it may shrink to nothing and was pressed down to its
label while the paragraphs around it kept their height. Everything inside
the two scrolling columns now keeps its size.

Below 1080px the halves stack and the panel scrolls; the single minmax row
that keeps the two-column layout inside the panel starved the release half
to zero height there. The rows go back to auto, and the ask sticks to the
bottom edge once the note is in view.
2026-09-20 16:48:31 +02:00
github-actions[bot] b98787f836 chore: bump version to 4.3.0 [skip ci] 2026-09-20 13:56:50 +00:00
Maurice dbb98a9bcd Merge branch 'main' into dev 2026-09-20 15:29:35 +02:00
Maurice 8215fed2a7 Merge pull request #2406 from liketrek/fix/pr2273-final-audit
fix: the findings of the final 4.3.0 audit, and dual-stack hosts in the SSRF guard
2026-09-20 15:23:02 +02:00
Maurice 9731407694 feat(notices): document sync joins the release notice as a fourth, wide card
Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology Drive are a headliner
of this release and were missing from the notice. The card runs across the row
under the three upright ones, picture beside the text, so the panel keeps its
height: the wordmark without its API pill, the two-way arrow, and the five
stores as tiles. The panel's grid row is capped at the panel's height, so a
short viewport scrolls the left column instead of clipping the support buttons
on the right. The headline counts four, the intro and the card texts are
shorter, and the Dawarich card drops its last sentence, in all 23 languages.
The release e2e test asks with the bundle version, as the client does.
2026-09-20 14:58:33 +02:00
Maurice 8f6ed02cff fix(notices): hand the release notice only to a bundle built for the running version
After an update the service worker keeps serving the previous bundle until
the new one is installed. That shell announces the release layout too, so
the layout alone let it draw the notice with its own older texts and use it
up for the version now running with one click on X.

The bundle now sends the version it was built as (client/package.json, baked
in by define), and the server compares it with its own before it hands a
notice with a release block over. A shell of another version is told nothing
and gets the notice after the reload, from the bundle that can draw it. This
holds for every update, not only the one that introduced the parameter.
2026-09-20 14:28:45 +02:00
Maurice 31239f3687 fix(ssrf): connect over every address a name resolves to, IPv4 first
The guard resolved a name to one address and pinned the socket to it. On a
dual-stack name whose AAAA came first that was an IPv6 many hosts cannot
reach on 443, and the request sat until the caller's timeout while the A
record would have answered at once. OIDC discovery, Dawarich, AirTrail, the
photo providers, a self-hosted router or Nominatim and the model endpoints
all ride this path.

The name is now resolved to every address it has, each one is checked, and
one blocked address blocks the name. The whole checked list goes to the
socket, IPv4 ahead of IPv6, where Node walks it itself and moves on after
its attempt timeout. Redirect hops do the same per hop. A caller that still
names one address gets exactly the old behaviour.
2026-09-20 13:54:31 +02:00
Maurice ed18c37903 fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.

Data and credentials
- A document sync connection edited to a new address no longer carries the
  stored token along: a blank form under another origin is refused, the same
  rule the probe route already had. Dawarich and AirTrail testConnection send
  the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
  trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
  says that the booking and its expense go with it. Deleting a place says the
  same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
  and its attachments; a real revision carries the attachments over.

Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
  hidden hotel stop no longer shifts it one slot early; the store sorts before
  it splices. Via re-anchoring, the booked-night guard and the sidebar's day
  membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
  booking session receives the new stop order too. The Public API and the iCal
  feed list a booked hotel once. A plugin can no longer write a stay against
  another trip's day.

Road trip and map
- The route overview paces its requests to the public routers and retries a
  rate limit, and it says which legs it could not route instead of quietly
  reporting a shorter trip. A click on a via handle no longer drops a second
  via; handles hide when zooming out. Read-only members cannot edit the driving
  limits. The phone shows a way back when no day is picked and prints the night
  drive departure in the chosen clock format.

Integrations and search
- Google transit keeps conventional trains when the train chip is on.
  TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
  accepts the whole boolean family. A caller-supplied description no longer
  lands in the instance-wide enrichment cache. Nominatim searches carry a
  timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
  knows what the Atlas already shows, and a host change with a new key clears
  the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
  deleting a connection unregisters its subscriptions, Nextcloud under a
  sub-path connects, Synology answers a name collision before uploading and
  verifies a move into the bin, Paperless titles are capped at 128 characters,
  a DNS failure reads as unreachable rather than as a policy verdict, videos
  from the store are accepted like uploads, and remote_missing has a way out.
  Conflict buttons only for the owner, a refused choice is reported, a refused
  credential gets a Reconnect button, disconnecting asks first on the desktop
  as it does on the phone, and the folder picker no longer spins forever.

Release
- The server holds the release notice back from a bundle that cannot draw it,
  so the previous shell served from the service worker cache after an update
  no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
  images say when the limit is reached and the new controls are translated.
  The transit backend switch reports a failed save. The Helm chart carries the
  Amap credentials in its Secret; the shipped templates document the Places
  API and Amap variables and the Overpass default. The wiki follows.
2026-09-20 13:54:31 +02:00
Maurice 1e35a99c11 fix(memories): search the photo provider by the local day, not the UTC one
"This day" sent the entry's calendar date to the provider as both bounds, and
both providers turned that date into a UTC window — Immich by concatenating a
Z suffix, Synology by relying on a date-only string parsing as UTC midnight.
For a reader at UTC+10 the searched window was 10:00 on the day to 09:59 the
next morning, so every breakfast photo was missing while the next morning's
turned up in its place, and nothing said the day was incomplete. The date
headings had the mirror defect, filing a 07:32 photo under the previous day.

Immich publishes the right anchor already: localDateTime is required on every
search result and is what its own timeline groups by. The window is padded by
a day on each side and the page narrowed again by each photo's own local date,
carried through as localTakenAt; a server that does not send it falls back to
the timestamp used until now, so nothing changes there. Because the padding
means raw rows and answered rows are no longer the same thing, the page is
filled server-side until it holds what the caller asked for, bounded so a
pathological album cannot spin.

Synology has no local stamp, so its anchor comes from the caller as a UTC
offset, resolved for the day being searched rather than for today — a day
looked up across a DST boundary would otherwise start an hour out. The MCP
tool takes the same optional input, defaulting to the behaviour it has now.

The picker's pagination sentinel moved out of the non-empty branch: a page the
new filter empties has to keep paging, and it now says so instead of claiming
there are no photos.

Closes #2336
2026-09-20 12:48:44 +02:00
Maurice a782e0e471 fix(map): make stops that share a spot reachable again
A trip that models one building as several stops — drop the bags, check in,
the museum inside the hotel — drew every pin on the same pixel, and only the
top one could be clicked, hovered or seen. Clustering is the mechanism for
exactly that: the plugin keeps children no zoom level can separate in one
cluster and fans them apart on click. disableClusteringAtZoom pinned the
group's own maximum zoom to 8, which both removed every cluster above it and
moved the only fan trigger into a zoom band a trip planner never operates in,
so the spiderfyOnMaxZoom set beside it could never fire.

The cutoff is gone. maxClusterRadius is a function of zoom instead: the same
generous radius as before in the overview, a pin's width above it, so stops
that are genuinely apart keep their own pins and a pile becomes a counted
bubble that fans open. Selecting a buried stop from the places rail now opens
the bubble it is hiding in rather than doing nothing visible, and it does that
without moving the camera, which stays the selection's own concern.

The GL renderer has no fan to open, so folding a pin there removes the only
thing that was left rather than providing a way in. It folds at a far tighter
radius — genuine coincidence, where nothing was reachable to begin with — and
draws the selected member of a pile, so the rail reaches it there too. A map
object without a projector now skips folding instead of dropping every marker.

The public share page spreads the same options and has no rail to fall back
on, which made it the worse case; it is fixed by the same change.

Closes #2344
2026-09-20 12:48:44 +02:00
Maurice 7ccc8dc03d fix(booking-import): say why a recognised booking could not be imported
A document the model read correctly could still come back as "No reservations
could be extracted". Every type mapper can answer null when the node carries no
reservationFor, or one with no name, and both call sites pushed the item only
if there was one — with no else. The unknown-@type branch next to them does
warn, so a node TREK did not recognise was reported while a node it recognised
but could not map vanished: no item, no warning, nothing in the log. Because
the file did produce a node, the "no reservations found" fallback was skipped
too, and the preview arrived empty and silent. Every recognised type could
disappear this way, on both the kitinerary and the AI path.

Each of them now warns, naming the file, the index, the type and the reason.

Three smaller silences went with it. The debug line labelled "Raw LLM Response"
printed the already-parsed array, so its inspect formatting — single quotes,
bare keys — read as a provider emitting broken JSON and sent this report at the
JSON parser, which has handled that shape since 4.0.0. A single reservation
answered on its own, with no array and no wrapper, was dropped by the list
unwrapper although the schema downstream would have taken it. And a response
nobody could read returned an empty list, which is the same answer as a voucher
with no booking in it; it now raises, on the Anthropic client too, where a
reply cut off at the token limit produced exactly this report's symptom. The
plugin AI surface keeps its old empty answer, since a value is what a plugin
contracts for.

The retry ladder gained a rung that sends no response_format at all, for
providers that support neither json_schema nor json_object.

Closes #2375
2026-09-20 12:48:44 +02:00
Maurice 2a7ad1e241 fix(reservations): answer an id that resolves to nothing, instead of crashing
The scoping guard asked whether a referenced id belongs to a different trip.
An id that belongs to no row at all cannot, so it passed, reached the raw
INSERT/UPDATE, and SQLite's own foreign-key enforcement threw. Nothing caught
it, so a stale place_id from a client-side race came back as a bare 500 with no
actionable message — where the same route answers a clean 400 for an id that
does exist somewhere else.

A second, disjoint guard now names the ids that resolve to nothing and answers
400. It deliberately does not cover accommodation_id: that column has no row to
find once a shortened trip cascades its stay away, and refusing the write there
would make the booking permanently uneditable.

The controller guard reads the request body, which is not enough on its own —
an update rebinds whatever the row already held, so a stored id whose row is
gone reaches SQL untouched. day_id, end_day_id, place_id and assignment_id are
all declared ON DELETE SET NULL, so a reference that resolves to nothing is
precisely the state that cascade leaves behind and is now cleared to NULL, the
same way a dangling accommodation_id already heals. day_accommodations' day
columns are NOT NULL and have nothing to heal to, so those are refused.

The plugin surface ran neither guard. It now refuses an unresolvable id, which
turns a crash into an error; it still accepts an id from another trip, because
taking that away belongs to the plugin contract and not to a bug fix. The
reservation MCP tools already validated every referenced id against the trip.

Closes #2355
2026-09-20 12:48:44 +02:00
Maurice 4b3fb7dec9 fix(oidc): never take a role away over a claim the provider did not send
Role mapping read the configured OIDC_ADMIN_CLAIM out of the userinfo payload
and collapsed two very different answers into one: the claim arrived and did
not name an admin, and the claim was not there at all. The second is not a
verdict about the user — it is what an operator sees when the scope carrying
that claim was never requested, or the claim name has a typo. TREK wrote
role='user' for it anyway, with nothing in the log, nothing in the audit trail
and nothing in the UI, so a misconfiguration read as an unexplained loss of
admin. The only guard was the last-admin rule, which protects an instance with
exactly one admin row and therefore almost nobody.

An absent claim now leaves the stored role untouched. A claim that does arrive
still promotes and demotes exactly as before. To keep the silence from moving
somewhere worse, an absent claim on an account that is already an admin warns
on every login, naming the account: providers that omit a claim rather than
send it empty — Okta's filtered groups, Entra ID — cannot take admin away
through the mapping at all, and that has to be done in TREK's own admin panel.
The ordinary case still warns once per claim name, listing the claim names that
did arrive (never their values) and pointing at OIDC_SCOPE. A real transition
writes an audit row from the controller, with the client IP.

Closes #2364
2026-09-20 12:48:44 +02:00
Maurice 5d04f9c400 fix(docker): move start-up into a script so an edited container still boots
The image carried its whole start-up sequence as one inline `sh -c` string
containing four single-quoted echo messages. Container management UIs render
Config.Cmd back into a text field and re-split it on submit with a quote-aware,
escape-unaware tokenizer, so editing a container in place — the usual way to
change an environment variable — shredded the quoting and left dash half an
if-block to parse. The container then died before Node started, with only
`TREK: 1: Syntax error: end of file unexpected (expecting "fi")` in the log:
the word TREK is the stranded fragment of the first echo, reported as $0.

The logic moves to server/scripts/entrypoint.sh unchanged, and CMD becomes a
single bare token, which has nothing left to mangle. A unit test pins that
shape — both exec-form lines parse, and every argv element stays one token
free of quotes and spaces — and the Dockerfile joins the test workflow's path
filter so that guard actually runs when someone edits it.

Closes #2374
2026-09-20 12:48:44 +02:00
Maurice 62cccf32f3 fix(db): give clipped trips their days back and let road-trip day numbers follow the trip limit
Two boot migrations. The first appends the missing dated days to a trip
the old 365-day cap clipped, only where the dated days still run unbroken
from the start date, so a trip that was re-dated by hand or lost a day in
the middle is left alone; dateless days that hold content stay behind the
dated ones as generateDays keeps them. The second rebuilds
roadtrip_day_boundaries without the CHECK that held day_number to 366,
which would have refused a boundary on a later day of a longer trip.

The road-trip contracts follow the same limit: the day boundary and
corridor schemas, the set_day_boundary tool and the day window's runaway
guard read MAX_TRIP_DAYS instead of their own 365 or 366, and the
tooLong message carries the number as a placeholder in all 23 locales.
2026-09-20 12:34:34 +02:00
Maurice c8d4ed896e fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it
A trip longer than a year stopped at day 365: generateDays capped the day
rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last
days had a date but no day to plan on, in the day list, the transport and
booking dialogs and the phone's date pickers (#2403).

The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the
server, both trip forms and the MCP tools, and a range past it is refused
rather than cut short: 400 on REST, an error result on MCP, bad params on
the plugin RPC, and a message in the forms before anything is sent. The
check runs before the trip row and the budget are touched, also catches a
start date moved past the stored end on its own, and holds a day_count
rebuild to the same bound. A trip that already carries a longer range can
still be renamed. The MCP-only 90-day ceiling on create_trip goes with it;
it clipped dated trips the same way.

Closes #2403
2026-09-20 12:34:34 +02:00
Maurice 44c92052a3 docs(wiki): bring the pages in line with 4.3.0
Every page was read against the code on dev. What changed:

- Pages that described 4.2 behaviour: satellite view on the vector maps,
  the mobile journal timeline, Collab with its fifth tab, the Review badge,
  clustered pins on the share page, the Leaflet preset list, the strict
  guard on fe80::/10, the invite link tools and their scopes, the Public
  API scope rules, the permission count in the plugin SDK.
- Features of this release that no page mentioned: videos in a journal
  entry, receipts on an expense, the Google transit backend, routing
  counters, the rating on a place, the navigation menu, car stops, the
  manual school holiday catalogue and its MCP tools, the Dawarich panel in
  the Atlas, the release notes modal, plugin frames going fullscreen, the
  corridor call into the search provider hook.
- Statements that sent an admin the wrong way: DEFAULT_LANGUAGE is not in
  the Helm ConfigMap, env.PORT cannot be changed on the chart alone, the
  rotation script does not cover the document sync credentials, event
  handlers cannot broadcast, there is no per user MFA reset in the admin
  panel, the demo user can edit, an unparsable APP_URL stops the server.
- Updating gets a section for 4.3.0: the TREK Places API as the new
  default, the new variables, the Overpass timeout, SSO sessions on an
  OIDC only instance, the migration that puts booked nights on the drive.
2026-09-20 12:11:41 +02:00
Maurice 033735777a Merge pull request #2401 from liketrek/feat/collections-gpx
feat(collections): export and import a list as GPX
2026-09-20 11:10:34 +02:00
Maurice d803efd39d feat(collections): import a file into a list that already exists
The import dialog always made a new list. It now asks where the places go:
a new list, as before and still the default, or a list that is already there.
The picker offers the lists the person may edit, which is their own plus a
shared one where they are an editor or an admin, and starts on the list they
have open.

Adding to a list only ever adds. A place the list already holds is left
exactly as it is, with its status, notes, rating and labels, and counted as
already there in what the import reports; it is recognised the way a single
save recognises one, by provider id, then name, then position. The list keeps
its own name, colour, icon and description whatever the file says, a label
the file brings is created only when the list has none of that name, and new
places are appended after the ones already there so a manual order survives.

The route is POST /api/addons/collections/:id/import, 200 because it adds to
a list rather than making one, and both imports now write their places through
the same helper.
2026-09-19 23:12:25 +02:00
Maurice 7cfec6452d fix(ssrf): let a listed link-local address through, such as a rootless Podman host gateway
A rootless Podman container reaches its host through 169.254.1.2, and an
identity provider behind that address failed the OIDC login with "Requests
to link-local / cloud-metadata addresses are not allowed", with no setting
to change it.

ALLOW_LINK_LOCAL_IPS names single link-local IPv4 addresses that may be
reached after all. The admin-configured lane (OIDC, AI parsing, plugin
OAuth, routing engines) reaches a listed address directly; the guard on
user-supplied URLs treats it as internal, so it also needs
ALLOW_INTERNAL_NETWORK there. 169.254.169.0/24 and 169.254.170.0/24, where
clouds serve instance metadata and credentials, cannot be listed, and an
entry that is not one link-local IPv4 aborts startup. Unset, nothing
changes.

Closes #2400
2026-09-19 22:39:12 +02:00
Maurice 00de3c3a1d fix(mobile): offer public transit in the day plan's leg menu, as on the desktop
The connector menu on the phone listed only the road profiles, so the
transit search could not be opened for a single leg there. It now carries
the Public transit entry whenever the trip has dates, seeded with the leg's
two ends and the departure of the stop it leaves, and the transport sheet
passes that departure on to the search.

The prefill is built by one module the desktop day plan now uses as well.

Closes #2398
2026-09-19 22:39:05 +02:00
Maurice 3ad40e056d fix(docsync): an orphaned binding stays stopped on every path that starts a run 2026-09-19 21:57:04 +02:00
Maurice 83c9cebddc docs(wiki): a booked check-out no longer drives the road trip schedule 2026-09-19 21:48:08 +02:00
Maurice 58f6a49f32 docs(wiki): document road trip, document sync, the TREK Places API and Dawarich
The 4.3.0 features had no pages, or pages that described an earlier
state. Three new pages and a rewrite:

- Road-Trip: the addon from enabling it to the phone tab, stays and a
  stop's End as the time you leave, pinned times and the backward
  schedule, daily travel times, the three driving limits, search along
  the route, via points, other ways and what avoiding tolls needs,
  following a track, the warning layer, MCP tools and troubleshooting.
- Document-Sync: switching stores on, binding a trip, what each store
  asks for and binds, direction and delete rules, conflicts, the
  mass-delete guard, renames and restores, what needs a look, limits,
  private addresses, and the MCP tools.
- TREK-Places-API: what the index is, where TREK asks it, what leaves
  the instance, switching it off or pointing it at a copy of your own,
  and what happens when it is down.
- Dawarich: brought up to date with suggestions, the journal fold,
  recorded routes, Atlas proposals and the wish list.

Places and search, documents, the addon and admin overviews, MCP
tools, environment variables, internal network access, the sidebar and
home link the new pages, and the in-app help picks them up from the
sidebar.
2026-09-19 21:45:16 +02:00
Maurice 03d3f2f91e fix(docsync): refuse an orphaned binding over MCP as the REST route does
A binding whose credential owner left the trip is orphaned: the
scheduler, the webhook and the REST "Sync now" route all refuse it,
because a run would use the credential of somebody who is no longer on
the trip. The MCP sync_trip_documents tool only checked the admin
switch, so any member with MCP could still run such a binding on the
ex-member's account.

The tool now skips an orphaned binding before anything is un-shelved,
reports it with the orphaned code, and answers with an error when no
binding of the trip could run at all.
2026-09-19 21:01:45 +02:00
Maurice 80bb06c84f feat(collections): export and import a list as GPX
A list could leave the instance as a TREK file, which only another TREK
reads. Export now asks for a format, and GPX is the second one: the
places as waypoints, for OsmAnd, Organic Maps, a Garmin or gpx.studio.
Import takes a GPX in the same dialog as a list file. Asked for in
discussion #2301.

The GPX is written from the very list file the JSON export returns, so
what may leave the instance is still decided in one place. Name,
description and address, notes, website and category go into GPX's own
elements, where every app shows them. Labels, status, phone and the
rest travel in a TREK extension namespace, so a GPX made by TREK comes
back as the same list. A place without coordinates cannot be a
waypoint; it is left out and the export says how many were.

A GPX is read on the server, sent as text, with the XML library the
trip importer already uses. A document with a DOCTYPE is refused before
parsing, which closes both external entities and entity expansion. Size
is capped like the list file, and the element count is capped before a
tree is built. The reader only turns the GPX into a list file: the
browser previews it and imports it through the existing route, so one
contract, one transaction and one skipped counter cover both formats.
Waypoints and named route points become places; tracks and unnamed
route points are counted in the preview and not imported.

The two list-file routes get a body ceiling of twice the file limit.
The global 100 kB cap refused most real favourites files, and it
already refused list files well under the megabyte the contract allows.
2026-09-19 21:01:45 +02:00
Maurice bb6003684c Merge pull request #2391 from liketrek/feat/document-providers
feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
2026-09-19 19:57:16 +02:00
Maurice 154c88b48a feat(roadtrip): leave a stop at its End and show the stay that makes
A visit's End could be edited and was shown in road trip mode, but the
schedule never read it: the drive always left a stop after its stay, so
a traveller who knew when they had to leave but not how long they would
stand there had no way to say so.

The End is now the time the drive leaves. RoadtripStop.leaveAt is
filled from the visit's End (or its place's) on the client by
useRoadtripRoutes and on the server by roadtrip-plan.service, so
calculate_roadtrip and get_roadtrip_context agree with the planner, and
computeSchedule takes it as the departure it already knew how to hold.
With Start and End both set the stay is the time between them. With
daily travel times on, the stay is the time until then, and an End
before the window start starts the day early the way a Start does.
Before the first fixed time the chain works backwards through an End as
it does through a pinned Start.

An End is read against the drive: the last passing of that clock time
counts as missed when the drive went past it on the road or earlier
the same day, anything else is the next one, so a drive across
midnight neither waits a whole day nor pretends to be on time. A drive
that gets there after the End leaves on arrival and files a
missedLeave finding, shown beside the late badge in the rail and on the
phone.

The rail's STAY badge, the inspector's Stay tile, the phone stop sheet
and the phone chain show the stay the End makes, with an "until 14:00"
hint. It is read off the schedule and never written to
duration_minutes: the stay belongs to the place, the End to one visit,
and a stored copy would go stale as soon as the arrival moved. For such
a stop the stay dialog, desktop and phone, shows that stay read only
and offers to remove the End, through the visit's time route with an
offline path (assignmentRepo.setTimes, setAssignmentTimes). While the
road trip is on, the End field in both place forms says the drive
leaves at that time.

The dead checkoutAt in the plan service is gone. New strings in all 23
locales.
2026-09-19 19:24:52 +02:00
Maurice cc1c16ec34 fix(assignments): keep untimed stops in place when a start is saved
Saving a start on a stop rewrote the whole day as the timed stops in
time order followed by every untimed stop. Its own comment and the
commit that added it promised that untimed stops keep their place, but
a day of two untimed stops and a third one given 14:00 came back with
the third one first. On a road trip the stop order is the drive, so
the day was re-routed, its vias landed on other legs, and collaborators,
who only got the one edited row, saw a third order until they reloaded.

The intended rule, an untimed stop inheriting the time of the timed
stop before it, now lives once as chronoOrder in @trek/shared, used by
both the day plan (dayMerge) and AssignmentsService.updateTime. The
server still reads a booked night's hour from its check-in, and a value
without a colon still sorts after every clock time.

The day is sorted only when the stop's start actually changed, so
taking an End off or saving an unchanged start no longer snaps back a
day somebody dragged out of time order. A sorted day is numbered from 0
like a drag. In the same transaction the day's road trip vias follow
the stop they were drawn behind, the planner's own drag rule, and a
via behind a last stop that stays last is kept, so the drive into the
next day keeps its road.

PUT /assignments/:id/time and the MCP update_assignment_time tool both
broadcast assignment:reordered with the whole day, to the writer as
well, and roadtripVia:changed when vias moved, so every client ends up
with the same order.

ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the
intended one.
2026-09-19 19:24:38 +02:00
Maurice 40f8912937 fix(collections): put the cover camera beside the close button
The custom image control on a saved place's cover sat in the top left
corner, on top of the category chip, and hid half of it. It also looked
like nothing else on the cover: a square dark button next to a round
translucent close button.

The camera and the remove image button now sit in the top right corner
next to the close button, camera directly left of the X, remove further
left when there is a custom image, all three in the close button's
round translucent look with its hover, phone size and focus ring. The
phone sheet had the same overlap and gets the same row.

The category chip and the controls share one header, so neither can
run under the other: the controls hold the end and never shrink, and
the chip ellipsizes a long category instead of sliding under the
buttons in the narrow docked detail. On the phone the header is in
flow, so a place without a category starts its name below the buttons.

Picker, tooltip, labels, the spinner while an upload runs and the
removal control behave as before. The trip planner inspector keeps its
avatar overlay; it never had the overlap.
2026-09-19 19:24:37 +02:00
Maurice 66061a05df fix(docsync): sort Papra's enumeration digest with an explicit comparator
SonarCloud flags a bare .sort() on strings (S2871). The comparator keeps
the byte order the default sort used, so every cursor already stored
stays valid.
2026-09-19 15:52:26 +02:00
Maurice bcdb931a34 fix(docsync): hand a cursor on only from a run that left both sides as listed
A run stored the cursor of the listing it started from, whatever it
did afterwards. Paperless, Papra and Synology build that cursor from
what they list, so a copy TREK uploaded and somebody deleted at the
provider before the next run left the scope exactly as the stored
listing had it. The next plain run (scheduler, webhook, Sync now) read
the cursor as unchanged, planned only the TREK side, and neither
flagged the copy as missing nor asked the mass-delete guard until
something else moved in the scope or a full run came along. A copy
TREK binned and somebody restored at the provider read the same way.
WebDAV was not affected: its root ETag never returns to an earlier
value.

The same held for a run that stopped short. What the transfer budget
left over, and a download that failed, was never planned again under
an unchanged cursor, so on a quiet binding those documents did not
arrive at all.

A run now keeps the listing's cursor only when it ended ok and asked
the provider to change nothing: no upload, no rename, no trash. Any
other run stores none, and the next one compares both sides in full
and hands a cursor on once it ends clean. On Paperless, Papra and
Synology that costs nothing, since they walk the scope on every run
anyway; on WebDAV it is one Depth:1 listing instead of the Depth:0
probe.
2026-09-19 15:13:02 +02:00
Maurice ec2711cec0 fix(docsync): name the store of a binding whose provider is switched off
Both shells keep a binding on screen after an admin switches its
provider off, and name its store through storeName(), which falls back
to a providerName on the link before the raw id. The server never sent
one, so such a binding was labelled with the provider id. publicLink
now carries providerName from document_providers.name whatever its
enabled flag, and every route and the MCP listing that return links go
through it.

The comments on docsync:changed in the realtime schema and the client's
event policy say that the event now also marks a binding created or
removed, and that the Files screen re-asks whether to offer the sync
button when it arrives.
2026-09-19 15:13:02 +02:00
Maurice 233f46575c fix(docsync): make the admin switch pause a binding, and retry a failed update
The check that was meant to stand a binding down when its provider is
switched off asked AddonsService.isAddonEnabled(link.provider_id). The
addons table has no rows for paperless, papra and the other document
providers, so it answered "off" for every binding, and every run came
back disabled before it listed anything. The unit test stubbed that
answer and the e2e never runs a sync, so nothing caught it.

isSwitchedOff() now asks the two things an admin actually switches: the
Documents addon, and document_providers.enabled for the binding's
provider. dueLinks leaves such bindings out in SQL, since one that
never runs never gets a last_sync_at and twenty of them would take
every slot of a tick. A run, the webhook on arrival and when its timer
fires, "Sync now" and the MCP sync tool all refuse before anything is
written, so a paused binding keeps its state and its documents' attempt
counters and resumes where it stopped. The REST route answers 409
provider_disabled, the MCP tool reports the same code, status() carries
providerOff per link, and both shells show one line saying the binding
is paused because its provider is switched off, in all 23 languages.

A failed pull_update also left the row stuck. upsertItem wrote the
listing's version, size and time next to the old bytes, so the next run
saw no upstream change, planned touch, which leaves error alone, and
the document stayed in error on stale bytes for good, "Sync now"
included. A failed transfer now leaves the pairing describing the copy
TREK holds; a new row keeps what pairs it but no version, so it is
fetched again.
2026-09-19 15:12:47 +02:00
Maurice 9de8490176 style(docsync): drop dash punctuation from the added comments and test titles
The lines this branch adds used em and en dashes as sentence
punctuation across comments, JSDoc, CSS comments and test titles. Each
one is rewritten with a comma, a colon, parentheses or a second
sentence, keeping what the sentence said.

Only lines this branch adds are touched; everything that already
exists on dev stays as it is. No logic, error code or logged message
changes. The only string literals affected are describe and it titles,
which now read "X: topic" like the rest of the provider tests.
2026-09-19 13:36:51 +02:00
Maurice 843582863c fix(docsync): offer the sync button only where something is behind it
The Documents addon put a "Document sync" button on every trip's Files
tab, and the seed ships every provider switched off, so after the
upgrade each of those buttons led into "No document providers are
available". The button now shows on a trip that is already bound, to
every member, and otherwise only to somebody who may bind the trip on
an instance that offers a provider. Without it the Files toolbar is
the one from before the feature, trash flush right, on both shells.

/api/addons carries no document providers for a normal user, so the
answer comes from the docsync routes through a new online-only
docSyncRepo and is kept per session in docSyncOfferStore. A revisit
renders the last answer at once and checks it again; offline nothing
is asked and the last answer stands. Creating a binding, removing one
and removing a connection now ping the trip with docsync:changed and
zero counts, so a member already on the Files tab sees the button come
and go without leaving it.

Both shells keep listing a binding whose provider the admin has since
switched off instead of claiming there are no providers, and name its
store through one storeName() helper. A load that answers 403 or 404
clears the bindings as well, so they do not linger once the Documents
addon is gone.

The phone admin screen gets the Documents shelf the desktop one has:
the document providers as sub rows under the addon, toggled through the
same admin route and re-read after Documents itself is toggled, because
the server switches the providers off with it.

isTripOwner and the isOwner prop become canManageSync and canManage,
which is what canManageDocSync answers. Both folder pickers take
loadScopes out of the sync object before their effect, so
exhaustive-deps is satisfied without the effect running on every
render.
2026-09-19 13:36:33 +02:00
Maurice 25d4e34959 fix(docsync): keep the Synology device token across a restart, tied to its row
The DSM trusted-device token lived in process memory only. After a
restart every token was gone and the stored one-time code long spent,
so every two-factor NAS binding stopped syncing until its owner typed a
fresh code, and nothing on screen said why.

The token is now kept in the connection's encrypted secrets, together
with a digest of the instance and account it was issued for. A saved
connection's ref carries a saveSecret callback that writes through
DocSyncConfigService.saveEarnedSecret, which rereads the row inside a
transaction so a sync run holding an old ref cannot undo a password
the owner changed meanwhile. The key belongs to no form field, so
maskSecrets never reports it and mergeSecrets never touches it: the GET
and PUT payloads do not carry it, an edit keeps it, and a client cannot
plant one. It survives a password edit and a spent or cleared code. A
connection repointed at another NAS or account drops it on its next
call, and a token DSM refuses with 403 or 404 is dropped in memory and
in storage. A probe of form values and an unsaved form may use what is
stored but never write. A refused token no longer shadows a fresh code:
the code in the form gets its turn in the same login call.

In memory the slot is keyed by the connection id together with the
row's created_at. A backup restored in place or the demo reset rolls
the id sequence back without a restart, and a new connection that
reused an id must not log in on the token of the row it replaced.

The Synology adapter receives its client through DI like Paperless and
WebDAV, instead of building a second instance beside the one the module
registers.

Two readings of a failed request are settled as well:
- INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an
  untrusted certificate again, so the settings screen offers the
  self-signed switch. Papra used to find them through the message text,
  and the shared classifier reads codes only.
- A host that never completes the TCP handshake reads as unreachable on
  every client. WebDAV said timeout before the shared transport; the
  change is now documented and tested.
2026-09-19 13:36:16 +02:00
Maurice 71400b7e85 fix(docsync): follow a rename on Synology, and settle restores and purges in one run
Where the id is the path, a rename or a move upstream lists the same
copy under a new id. The upstream-only loop re-paired it with a touch,
which went wrong twice. The pairing had already been counted as
vanished, so the same run flagged it missing as well and counted it
towards the mass-delete guard. And touch wrote the provider's new name
into remote_name while the trip file kept the old one, so the next run
read that as a rename made in TREK and renamed the provider's copy
back. The heuristic also matched on a content hash only, which Synology
never sends: on the one provider without stable ids it did not fire at
all, and every rename there ended as a missing flag plus a second
download of the same bytes.

The planner now moves such a pairing first, with a relocate action that
updates remote_id and the version and leaves the agreed name alone. The
pair loop then treats it like any listed document, so the new name
comes into TREK through rename_local exactly as it does where ids are
stable, and nothing counts as vanished. A copy is recognised by its
content hash where the listing has one, otherwise by size and
modification time, which a rename or a move leaves untouched. Only a
one-to-one match is followed, and a copy TREK binned itself never ties
with a live twin of the same bytes. The rows now load remote_size and
remote_modified_at, which were written on pull and never read; touch
keeps them current and a push records them. rename_local and the
provider branch of resolveConflict take the provider name in its
cleaned form, since a raw name with a colon or a slash read as a rename
made in TREK on the next run.

Restores and purges:
- A file taken back out of TREK's trash whose copy somebody else had
  deleted meanwhile resumed as synced and was then counted as vanished,
  so a few such restores tripped the mass-delete guard on every run
  until the circuit opened. It now lands as remote_missing directly,
  outside the guard, and the service asks for a full listing while such
  a restore is pending.
- A row in error that once held a file now reads as a deletion once the
  file is purged, told apart from a first download that never landed by
  the agreed hash. "Sync now" used to download the deleted document
  again.
- Moves are followed before a restore is judged, so a binned copy
  restored at the provider under a new path resumes its pairing instead
  of being duplicated on both sides.
- relocate and both renames lift a remote_missing flag the way touch
  does.
- A double rename under trek_wins or provider_wins is settled with a
  rename instead of a transfer.
2026-09-19 13:35:55 +02:00
Maurice 98e2938033 fix(maps): let an uploaded place image fill its marker
A place with an uploaded image showed a plain disc of its category
colour on the map, while a picked suggestion looked fine. The marker
sized its img with width and height attributes, and stylesheet rules
beat those: Tailwind's preflight gives every img height: auto, and
Leaflet's marker pane adds width: auto. Suggested and auto photos reach
the marker as 48px square thumbs, so nothing showed. A phone photo kept
its own proportions on the GL map, a sliver inside the circle, and its
full pixel size on Leaflet, where only the rounded-off corner of a
4000px image lands inside the circle. Both maps now build the img
through one helper that sizes it in its style, so object-fit: cover
crops any photo to the circle.

The thumb loading on both maps was keyed on place ids, so a change of
picture under the same place went unnoticed until a reload:
- Taking an upload off a place left the category dot instead of
  bringing its auto photo back.
- Picking another suggested photo kept the thumb of the first one,
  which was cached under the provider id. A picked photo is now its own
  cache key.
2026-09-19 13:35:44 +02:00
Maurice b88b92add7 fix(docsync): a paused binding no longer resets its own settings
PATCH links validated its body with a schema derived from the create
contract through .partial(). In Zod 4 a .default() still fires inside an
optional field, so a patch naming one setting arrived with every other
one filled in: pausing a pull-only binding switched it to two-way, reset
both policies and blanked its label. The update contract is now written
out without defaults, and an e2e test drives a patch through the real
pipe, which the controller unit tests never see.

Also, the cosmetic leftovers from the review:
- POST connections/:connectionId/scopes no longer wants the connection
  in the body as well. It was required, never read, and let one request
  name two connections; a client that still sends it has the key
  stripped rather than refused.
- createLink loses the scope argument every caller passed as null.
- The controller's owner check is now assertCanManage, because it lets
  instance admins through just as canManageDocSync does on the client.
  Who may manage a binding is unchanged.
- document_provider_fields drops settings_key and payload_key. Nothing
  read them: the connect form takes its fields from /docsync/providers
  and the admin shelf sends none for document providers.
- The e2e test imports the provider classes under their new names.
2026-09-19 12:07:28 +02:00
Maurice 01a7224f29 fix(docsync): key the Synology device token per connection and share one transport
The DSM trusted-device token was cached per NAS and account. A second trip
whose owner knew the account password but never had the authenticator could
bind the same account and log in on the first trip's token, a two-factor
bypass across trip owners. Nothing ever cleared the entry either, so a token
DSM stopped trusting went out ahead of any fresh code on every attempt until
the process restarted.

The token is now filed per connection, with instance and account still in
the key. An edit keeps the connection id, so the token survives a password
change and a stale code in the form. An unsaved form (connection 0) keeps
what its probe earns on the session only, and the saved connection adopts
it from there. A token DSM refuses with 403 or 404 is dropped for that
connection alone.

The four clients each carried their own copy of the transport, and the
copies had drifted. provider-http.ts now holds the SSRF-guarded fetch with
its timeout and certificate switch, the reading of a request that got no
answer, the base status table and the guard on a streamed download.
Timeouts, caps, auth headers and each client's error class and wording are
unchanged. Where the copies differed by accident they now agree:

- Paperless recognises UNABLE_TO_GET_ISSUER_CERT_LOCALLY (a private CA) as
  tls_untrusted instead of unreachable.
- ERR_SSL_* handshake failures read as unreachable on WebDAV too, since the
  self-signed switch cannot fix them.
- Synology maps HTTP 409, 415 and 507 like the others, Paperless maps 415.
- Every download is refused on an announced length over its ceiling and
  counted as it streams. WebDAV and Paperless had no ceiling, Synology only
  read the header, and Papra's limiter left the source pinned when it
  tripped.
- Papra reported a download without Content-Length as zero bytes, so the
  size from the listing was never used.

PapraProvider and SynologyDriveProvider are renamed PapraDocumentProvider
and SynologyDriveDocumentProvider, matching the other adapters.
2026-09-19 12:07:28 +02:00
Maurice dd3d97589b fix(docsync): act on a deletion once, and know what to do when it is undone
Both planner branches planned local_deleted for as long as a file sat in
TREK's trash. Under unlink nothing showed, until the binding was switched
to trash: the next run then binned every document ever deleted in TREK,
months back included. A binding on trash from the start asked the provider
to bin the same copy on every run. A local_deleted row is now left alone,
so the policy is applied once, under the policy in force at that moment.

That guard also meant a file taken back out of TREK's trash stayed on
local_deleted for good, which it already did before: no transfer, no
upload, no flag, and a second deletion of the same file would never have
propagated. The row now leaves that state by what became of the provider
copy:

- still there: the pairing counts again and the row is judged like any
  synced one, so an upstream edit made meanwhile comes down;
- binned by TREK: the pairing is dropped and the file goes up again as a
  new document through the ordinary push, keeping its trek_doc_uid;
- deleted by somebody else: flagged remote_missing, never uploaded over
  their deletion.

Telling the last two apart needs a record of what TREK did, so the new
remote_trashed_at column (migration 239) is written when TREK bins a copy
and cleared when the pairing is back, or when the binned copy is listed
again because somebody restored it at the provider.

A file purged from the trash came straight back as well: file_id is ON
DELETE SET NULL, and the row read as a download that never landed. A
tombstone now stays a tombstone, and a purge no run had seen yet is
treated as the deletion it is.

Also drops snapshotHash, which nothing read.
2026-09-19 12:07:28 +02:00
Maurice dc1ac0ae85 fix(docsync): give the sync flow its own TREK icon and restore the wordmark
The document-sync flow needed TREK's square app icon next to the
provider glyphs, and the PR got it by rewriting the shared TrekMark
component. TrekMark is the wordmark with the API pill that the admin
Places API card, the mobile admin block and the release notes popup
draw, so all three showed the app icon instead, and
AdminSettingsTab FE-ADMSET-031 failed on the missing "TREK Places API"
image.

TrekMark is back to its dev version. The app icon now lives in
TrekIcon, which DocSyncFlow and MDocSyncSheet use.

AddonManager had also switched Synology Photos to the Synology Drive
glyph, which renders as a block and sat off-line from the Immich row
under Journey. Photos keeps its own icon again. The new Documents
shelf gets tests: its rows and glyphs, staying out of the addon count,
hiding while Documents is off, the re-read after Documents is switched
off, and a provider toggle that saves without re-reading.
2026-09-19 12:04:18 +02:00
jubnl ac47066a93 change oidc behavior with remember me on by default when oidc only enabled (#2397) 2026-09-19 09:12:15 +02:00
Maurice 9f531d1cd9 feat(docsync): make the conflict answer real and give a conflict a way out
The binding asked at setup what should happen when both sides changed, stored
the answer, and nothing ever read it: every conflict was manual whatever the
binding said. The planner now acts on it — provider_wins pulls, trek_wins
pushes, anything else parks the row — and never in the direction the binding
forbids.

Conflicts themselves had no exit in either shell. The server has resolved them
since the first version and the strings have been in the locales just as long,
but neither the panel nor the phone sheet ever listed the rows, so the only way
to answer one was the MCP tool. Both shells now list them through one shared
hook with the same three answers.

Also:
- The desktop connect dialog shows a refused save instead of looking as though
  nothing happened.
- An error the server phrases as a sentence no longer reaches the screen as a
  raw translation key.
- The scope pickers depend on the stable callback rather than the hook object,
  so opening one no longer re-lists the provider's folders on every render
  above it.
2026-09-19 00:57:57 +02:00
Maurice e6a5ca1521 fix(docsync): stop a push from stealing another file's pairing
Papra answers a push of bytes it already holds with the document that has
them. Two trip files with the same content therefore pushed to one document,
and the unique index on (link_id, remote_id) aborted that run and every run
after it with a constraint error. The pairing now stays with the file that
owns it and the second file is recorded as a conflict.

Also in this pass:
- Paperless no longer adopts a same-bytes document that lacks the scope's tag
  when a consume outlives the poll budget; that document belongs to someone
  else's filing and delete-through would have trashed it.
- The pull path writes the file row, the retirement of the copy it replaces
  and the pairing in one transaction, and announces them only once committed.
- PATCH links stops accepting remoteRootId/remoteRootPath, which it read and
  silently dropped.
- The MCP sync tool clears shelved items before running, as the REST route
  does.
2026-09-19 00:44:43 +02:00
Maurice 7237a1881d fix(docsync): switches that did not switch off, a webhook nobody could authenticate, and a counter that only grew
More from the audit, each checked against the code before it was touched.

The webhook obeyed neither the Documents addon nor the app_settings kill
switch, so "switch document sync off" stopped the poll while every provider
holding a webhook carried on driving full runs. Both are now read on arrival
and again when the debounce timer fires, so a switch thrown mid-window still
takes effect.

Nextcloud's subscription registered its secret under `X-TREK-Docsync-Signature`
while the endpoint reads `x-trek-docsync-secret`: the subscription existed, the
calls arrived, and every one of them was dropped for carrying no secret the
controller could find. The two names now agree, and the test pins the one the
endpoint actually reads.

A WebDAV create was an unconditional PUT, so a name already taken upstream was
silently overwritten — two trips bound to one folder, or a `receipt.pdf` a
person put there by hand, is enough. Creates now send `If-None-Match: *`.

The per-item retry bookkeeping was stuck at both ends: the backoff was computed
at step 1 every time, so three of the curve's four steps were unreachable, and
the attempt counter only ever grew, which shelved a row that failed once a
month after six months of otherwise healthy syncing. A successful pass now
clears it, which is what makes the limit mean six failures in a row.

Also: a manual run on an orphaned binding is refused with 409 (its credential
belongs to somebody no longer on the trip, and the scheduler already refuses
it, so the button was a way around that); a document deleted while a run is in
flight is no longer uploaded anyway; a path-shaped WebDAV id containing `..` is
rejected instead of addressing a file outside the bound folder; and Papra's
multipart part header no longer takes an unescaped media type, which a CR could
end early.

Client: three writes in useDocSync swallowed their failure into `void` call
sites, a load failure blanked the panel to "no providers available" whatever
the cause, and overlapping loads let the slowest response win. Writes now
report, only 403/404 blanks the panel, and a generation stamp keeps the newest
answer. The dead lastRun state went with them.
2026-09-19 00:31:13 +02:00
Maurice 4fa06c3483 fix(docsync): an idle WebDAV binding read as an emptied folder, and two ids nobody checked
The audit's worst find, reproduced against a live Nextcloud before and after the
fix: the WebDAV adapters answer "nothing changed here" by returning an EMPTY
listing plus `cursorUnchanged`, and nothing read that flag — `grep -rn
cursorUnchanged src/` found it set by four adapters and consulted by none. The
core therefore saw a folder that had lost everything. Run 1 and 2 look fine
because the run's own uploads move the ETag; from run 3 on, every run of an
idle binding ends `partial` with `mass_delete_guard` and six documents reported
missing, forever, until failure_count opens the circuit and the scheduler drops
the binding entirely. That is the ordinary case — a binding nobody is touching.
planReconcile now takes `remoteUnchanged` and plans only what happened in TREK.

Two ids were authorised by trip and never checked against it:

  - resolveConflict took an item id on trust, so the owner of any trip could
    decide a conflict belonging to somebody else's. Both are plain integers and
    nothing compared them.
  - The connection test merged the STORED credential into a probe of whatever
    baseUrl the caller named — a way to have TREK post another account's API
    token at a server of the caller's choosing. Stored secrets are now reused
    only for the address they were stored against.

The phone's connect form was wrong in three ways at once, each of which the
desktop had right: it read the address as `baseUrl` while the field is
`base_url` (so every connection it saved carried an empty address), it drew
`allow_insecure_tls` as a text box and always sent false, and it built label
keys as `docsync.provider${cap(label)}` when the label column already holds the
full suffix — `docsync.providerProviderApiKey`, rendered raw on screen. Those
rules are not markup, so they now live in `useConnectForm`, which both shells
use. Its test fixture invented the field shape and so agreed with the broken
sheet; it now mirrors document_provider_fields.

Also: the scheduler read its interval once at bootstrap while this file's own
comment promised a per-tick read, so changing it did nothing until a restart;
and dueLinks ordered by a column that a successful run clears, which left every
binding tied and SQLite answering in insertion order — on a database with 170
bindings the same twenty ran on every tick and nothing created after them was
ever synced automatically.
2026-09-19 00:07:33 +02:00
Maurice 1430056d24 fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI
The attempt counter and the backoff were written on every failure and never
consulted when planning the next run: rows were found in a live database at 46
attempts against a limit of 6, so a document a provider refuses — one it reads
as corrupt, a type it will not take, a full quota — was re-uploaded on every
cron tick forever. planReconcile now skips a row that is shelved or still inside
its window, and a manual run clears the counters, which is what "waits for a
person" has to mean.

The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed,
with a comment explaining it, and nothing read it. Providers fire once per
document, so twenty files dropped into a watched folder were twenty runs — the
in-flight guard answered `busy` to nineteen of them, throwing away nineteen
announcements, and the one run that did start had begun before most of the
changes landed. A burst now folds into one trailing run, and the binding is
re-read when the timer fires so a sync switched off in between does not get a
last run out of a stale row.

Four defects the new suites turned up, all fixed: the sync button lost its
accessible name below sm, where its label is hidden; the stored address was
never shown in the connect dialog although the form kept submitting it; the
allow-self-signed switch only read the connection at mount, so a stored "on"
showed as off and the next save wrote that back; and a refused folder create
escaped as an unhandled rejection with nothing said to anybody.

Also here: the phone shell gets the same feature (one sheet, four views, the
same hook as the desktop), the marks come from the product's own icon files
rather than redrawn approximations, and the 22 non-English locales carry the
new keys.

ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the
test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
2026-09-18 22:44:45 +02:00
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
2026-09-18 19:23:07 +02:00
Maurice 902dbbf84c Merge pull request #2390 from liketrek/fix/roadtrip-day-routes
fix(roadtrip): a stop on a dead end took the whole day's routing with it
2026-09-18 14:58:16 +02:00
Maurice d4c6ac9fe8 fix(costs): the settlement fixture carries the rate its type never had
A foreign-currency payment gained an exchange_rate in the ledger fixture without
the field on the local Payment type beside it, and the client typecheck has been
red since.
2026-09-18 13:18:04 +02:00
Maurice 5ef8bf31f4 fix(roadtrip): a stop added by hand opens on the kind being looked for
The button carried nothing and the form hard-wired the kind, so the categories
sitting directly above it had no way through and every manual stop started life
as a fuel stop with ten minutes on it. The click now carries what the corridor
is looking for, dwell included. The picker is multi-select, so the first kind
the panel lists wins rather than a fallback nobody switched on; with nothing
selected the form falls back on its own, because a stop without a kind is a
numbered destination that counts in every total.

This also closes the case it was noticed on: an electric car seeds the panel to
charging, so the picker said Charging over a form that said Fuel.
2026-09-18 13:17:59 +02:00
Maurice e9742e9322 fix(roadtrip): a refused run asks again one pair at a time
One request per day is thrifty, and the price of it is that a refusal is never
local. With the usual cause gone the shape of the failure is still there, so a
run the router refuses outright now asks for its legs one at a time instead of
leaving the day blank. The pairs are enqueued rather than awaited, so they keep
the spacing of every other request, and a rate limit is never split: answering a
host that asked for less traffic with four more requests is the opposite of
backing off. A refusal the host meant skips the two retries, because the same
coordinates earn the same 400.

Seam legs are merged under the day's own runs now rather than over them.
legsByDay is replaced whole on every routing round while seamLegs only ever
grows, so a pair that used to sit on a day boundary and now sits inside one day
kept an answer nobody re-asks for, shaped by the vias of a stop that has moved.
2026-09-18 13:17:51 +02:00
Maurice 371b7da9de fix(roadtrip): let a drive turn round at a stop it passes through
A day is routed as one request over all of its stops, and OSRM's car profile
forbids a u-turn at an intermediate waypoint unless asked. When it cannot obey
it refuses the whole request rather than the one leg, so a single stop on a dead
end took every leg of the day with it. Gouffre de Padirac snaps onto a cave
access road that goes nowhere else: each of that day's pairs routes fine alone,
all five together come back 400 NoRoute, and the same five with
continue_straight=false come back with their legs.

The permission goes on every request that has a stop in the middle, in the
browser and in the server's planning path, which had the same defect. Two-point
requests keep the url they had.

OSRM refuses an unknown query parameter outright rather than ignoring it, so a
router that has never heard of this one is recognised by its InvalidQuery,
asked again without it, and remembered per origin.
2026-09-18 13:17:43 +02:00
Maurice 7f87dd512d fix(costs): read a foreign-currency expense at the rate it was booked at
The server freezes the FX rate when an expense or a settle-up transfer is
entered and settles the balances with it, but the client ignored the column
and converted every amount with today's rate. A bill paid in dollars was
therefore worth something different each time it was opened, which a tester
noticed after settling up: the figure moved under him, and the row no longer
matched what had actually changed hands.

convertBooked applies the server's own rule, in one place for both shells and
the transfer rows: an amount already in the trip currency is left to the live
step, a frozen rate converts to the trip currency before the live step, and a
rate of 1 or none at all is a row written before the freeze existed and keeps
converting live.
2026-09-17 22:04:01 +02:00
Maurice 0e2d255d81 test(roadtrip): find the phone's navigate link by its state, not its label
The Up next card stopped printing a fixed key when the audit replaced the
nonexistent places.navigate with the single navigation target's own name, so
the assertion went looking for text that no longer exists and took the 4.3.0
branch red. The link is found by aria-disabled now, which is what the test is
actually about.
2026-09-17 22:03:52 +02:00
Maurice 543a052da7 Merge pull request #2387 from liketrek/fix/pr2273-audit-0917
fix: eleven from a full audit of the 4.3.0 release branch
2026-09-17 16:55:23 +02:00
Maurice 7806560c8f fix(roadtrip): four more from the audit, each one traced to what the user would see
**"Today" was read off UTC while the clock beside it is local.** `localDate.ts` exists for
exactly this and says so in its own comment: between local midnight and the UTC rollover the
two disagree, so east of Greenwich the up-next card hung on yesterday's stage for the first
hours of every night, which is when somebody on a long stage is actually driving. Both the
stage hook and the corridor hook now ask the same helper the rest of the app asks.

**A refused day end left the switch showing a state the trip never reached.** The writer
catches its own failures and shows a toast, so it never throws and the sheet's catch could
not fire, which left the optimistic value standing. It answers whether the day end moved
now, and the switch comes back when it did not. The desktop control ignores that answer, as
it reads its state back from the trip.

**A timed-out corridor search reported more unsearched boxes than it had.** Every worker
still alive ran the deadline branch and each added the whole remainder, so three workers
reported three times the boxes left, which can exceed the number of boxes there ever were.
The remainder is claimed before it is counted, which also ends the other workers' loops.

**A night that fell mid-leg had its morning drive dropped from the stage map.** The join
mark went on the first leg of every day opening on an automatic night, but only a night
standing ON a stop is a connection between two days. `dayWindow` records that difference as
a whole `position` against a fractional one, and where it is fractional the morning drive is
the last stretch to this day's own first stop. It was being filtered off the stage while its
distance stayed in the day's total.
2026-09-17 16:49:03 +02:00
Maurice c8f9edad87 fix(i18n): the corridor's truncation note takes a singular, and three plurals were wrong anyway
`mobileTrip.rtTruncated` interpolated a count into a sentence naming the stretch once, so at
one truncated area it read "1 stretches". Two keys now, picked by the count, the shape this
codebase already uses where a number changes its noun.

Writing the singular turned up three plurals that were wrong at counts the old one-form key
could never reach. Polish and Czech both had the genitive plural with a singular verb, which
is right at five and wrong at two; Arabic had the 3 to 10 form, wrong at two and again from
eleven. All three now put the count after a colon, where no form has to agree with it.
Russian and Ukrainian were already correct at both, since their phrase leads with a
locative, and were left alone.

The eight languages that keep the singular noun after a numeral differ only in the numeral,
which is what they should do.
2026-09-17 16:40:05 +02:00
Maurice 92ccd24eed fix(roadtrip, journey): six the audit turned up, each one verified before it was touched
**A saved stay left an invisible sheet standing, and the day swipe stayed dead behind it.**
The stay sheet reopened 'rtstop' by spreading its own payload, which carries a place id.
That sheet is located by day and assignment, so it resolved to nothing and drew nothing,
while `shell.sheet` still pointed at it and `menuOpen: !!shell.sheet` kept the day swipe
blocked until something else closed it. The row rides along now, both ways, and with no row
to go back to the sheet closes rather than opening one that resolves to nothing.

**"End the day here" showed off for a day that already ends there, and switching it on
deleted that day end.** Two things close a day: the stop's own flag and a manual boundary
filed against it. The writer has always known about both; the phone switch read only the
flag, so the tap ran through the writer's boundary branch and cleared the boundary. The
question is asked once now, in the planner beside that writer, and the desktop reads the
same answer it always computed inline.

**The road trip chain printed 24-hour clocks at a reader on a twelve-hour one.** The model
hands clocks over as HH:MM, which is what the schedule computes in, and the chain, the head
card and the up-next card printed it straight. The desktop rail, the day timeline and this
tab's own sheets all honour the setting; these go through the same formatter, carried in the
row chrome so no two rows can disagree.

**The up-next card's navigate button read "places.navigate".** No locale has that key. It is
`inspector.navigation`, which is what the stop sheet beside it, the place inspector and the
place sheet all use, with the single-target label when there is only one.

**Offered roads were invisible on a satellite basemap.** The imagery is anchored under the
first layer TREK draws, found by prefix, and the roads offered for a leg are added before
the route source so they sit under the current route. That also puts them before the first
`trip-` layer, so the raster went straight over them. The prefix list knows them now.

**A journal grew a day section for every day Dawarich recorded, all year.** The hook reads
every open stay the account has, and the timeline took all of their dates. A week in May
therefore sprouted sections from September. Bounded by the journal's own span now, its
entries together with the dates of the trips it links, and with no span at all it offers
nothing rather than everything.
2026-09-17 16:38:17 +02:00
Maurice 08df1812be fix(sonar): spell out the order these five sorts already have
SonarCloud's S2871 fires on a bare .sort() over strings and asks for localeCompare, and it
had five of them on new code, which is what put new reliability at D and failed the gate.

All five sort ISO dates or query parameter names, where the default lexicographic order is
exactly the one wanted. Taking the rule's suggestion literally would have been the wrong
fix twice over: a locale-aware comparison reorders dates differently per runtime, and on
the Amap signature it would produce a canonical string Amap itself does not compute, so
every signed request would come back rejected.

So the comparator is written out rather than changed. Byte order, explicitly, with a line
saying why it has to stay byte order. Behaviour is identical: the 43 Amap cases, the 40
track cases and the 30 client cases over these modules all pass unchanged.
2026-09-17 16:19:02 +02:00
Maurice ce5cc49b58 Merge pull request #2384 from liketrek/feat/roadtrip-mobile
feat(roadtrip): bring the road trip addon to the phone
2026-09-17 15:33:47 +02:00
Maurice 8860e16149 test(journey): cover the Dawarich fold, and mend what the switches and the wiring assumed
Three things the CI would have caught.

The settings dialog's switches are the shared ToggleSwitch now, which is a button with
aria-pressed rather than a role="switch" with aria-checked, and three tests still looked
for the old shape.

The page's wiring test mocks the whole hook, so the day map the timeline reads was
undefined there and every case in the file died on it. The mock carries it now, empty,
because the fold has a suite of its own.

That suite is new: what it pins is the fold rather than the rows inside it, which the
panel's own tests already cover end to end. That it says nothing on a quiet day, that the
line reads shut, that the count takes the singular at one, that it starts closed, that
both actions reach the journal with the stay they belong to, and that the span follows
the traveller's own clock.

The grouping the hook does moved into the model beside the other pure shaping, where it
is four cheap tests instead of a round trip through MSW: by day, in the order they were
lived, only what is still waiting, and no bucket at all for a day whose stays are all
handled.
2026-09-17 15:32:19 +02:00
Maurice 185f6c5335 Merge pull request #2373 from liketrek/fix/journey-skeleton-per-day
fix(journey): a place kept across two days is two days of the journal
2026-09-17 15:31:37 +02:00
Maurice 1e338e2c71 Merge pull request #2383 from liketrek/fix/mobile-markdown-place-description
fix(places): render markdown in the mobile place description
2026-09-17 15:31:13 +02:00
Maurice 2879433bd3 fix(dawarich): the panel opens to its full height and the rail scrolls past it
Capping it was the wrong half of the fix: the stays were reachable but the panel was cut
off at half the rail, which is not what opening something means.

It sits inside the places list's own scroller now, above the rows, so it simply grows to
whatever the day's stays need — 3350px for forty of them — and the rail scrolls past it
to the places. No second scrollbar, no fold. As a band of its own it could never do that:
the list is the flex child that scrolls, so anything unbounded above it squeezed the list
to nothing and took the rail's scrolling with it.
2026-09-17 15:05:29 +02:00
Maurice 1908d6f5f9 fix(dawarich): the places rail scrolls again with the panel open
The panel sat in a wrapper that could not shrink and did not scroll, so opening it took
whatever height the day's stays needed — forty of them is over 3000px — and squeezed the
places list, which is the flex child that scrolls, down to nothing. The rail stopped
scrolling and most of the stays sat below the bottom edge with no way to reach them.

The wrapper is capped at half the rail and scrolls on its own. Open, the stays are all
reachable and the list underneath is still a list; shut, nothing changes, because the cap
only bites once the content passes it.
2026-09-17 15:02:58 +02:00
Maurice 425e1ef183 fix(dawarich): the trip rail drops the duration badge
A stay in the trip rail showed both when it happened and how long it lasted. The rail is
narrow and the row does not wrap, and of the two it is the arrival that places a stop; how
long somebody stood there is a second figure competing for the same line.

Off wherever the panel is scoped to a trip, which is the planner rail and the phone's
places browser, and the same test `showTrip` already uses. The journal keeps it: there a
stay becomes an entry, and how long it lasted is part of what the entry says.
2026-09-17 14:58:30 +02:00
Maurice 9f57bbf4fb fix(dawarich): the stay times follow the clock the traveller set
Every time Dawarich shows was cut straight out of the timestamp and printed, so on a
12-hour setting the app said "2:05 PM" everywhere and "14:05" here. Both ends of a range
go through the same formatter the rest of the app uses now, and the argument is
required rather than optional so the next caller answers the question instead of
inheriting the default.

That covers the rows in the panel, on the phone and in the planner, and the summary line
a journal day carries, which reads its span through the very same function so the fold
and the stays under it cannot disagree about the clock.

The accept dialog was already right: it fills a CustomTimePicker, which knows the
setting, from the 24-hour value it keeps internally.
2026-09-17 14:53:58 +02:00
Maurice 3dd3e1e8d2 fix(journey): the day count stays the journal's own
A day a pending stay was waiting on now joins the timeline so the stay can be reached,
and the header counted those days as part of the journey: a journal of seven days read
"11 days" because Dawarich had recorded on four more. The figure comes off the entries
again, which is what the word means.
2026-09-17 14:47:23 +02:00
Maurice bbcf17d816 feat(journey): the day carries its own Dawarich stays, folded
The stays Dawarich recorded used to be a panel above the timeline: every open stay of
every day, stacked over the entries. A fortnight of driving puts forty of them between
the reader and their own first entry, which is the scrolling Roel described on the
16th. They belong where the day is.

Each day of the timeline now ends in one line: the Dawarich mark, how many stays, and
the span they cover. A tap opens them, and what opens is the panel's own rows, imported
rather than copied — one row this detailed in two places would be two things to keep in
step and a straight hit on the duplication budget. Nothing is drawn on a day with
nothing pending.

A stay falls on the day it happened, which is often a day the journal has no entry on
yet, and that is the whole point of offering it. Those days join the timeline so their
stays can be reached at all; without that the only way to a quiet day's stays would be
to write an entry on it first. Not while a search is running, where a day whose entries
the query filtered out is not a day the reader is looking at.

The count takes two keys picked by the number, the shape this codebase already uses for
a noun that changes with it: there is no plural engine in the i18n layer, and one key
with a placeholder in it reads as "1 stays". Russian, Polish, Czech, Ukrainian and
Arabic inflect again at 2-4 and 5+, which two forms cannot express, so their plural puts
the number after a colon, where no form has to agree with it.

The phone is untouched: it opens the same list in a sheet from its own button, which is
where it works on a screen that shows one entry at a time.
2026-09-17 14:30:14 +02:00
Maurice d18077d171 fix(journey): the Dawarich button clears the card rail, and the settings switches read in the dark
Two on the journal.

The phone's Dawarich button floated a hard-coded 128px over the dock, which is short of
the rail underneath it: a day scrubber over a row of covers, and a cover grows from 152
to 180px when it becomes the active one. The button sat on the cards. It now clears the
rail's measured height, observed rather than read once, because that height changes with
every swipe. No rail, as on an empty journal, means no offset.

The settings dialog drew its four switches by hand in emerald and zinc, with a white
knob. In dark mode that is white on near-white: the screenshot is three pills where
neither the state nor the knob can be made out. They are the shared `ToggleSwitch` now,
which is the component every other switch in the app uses and which solved this exact
problem with `--accent-text` on the knob. The row is no longer one big button, since a
button cannot hold another: text left, switch right, the layout the settings screens use.
2026-09-17 14:10:52 +02:00
Maurice 84923b97a7 fix(roadtrip): the connection built by a day window is a connection too
The last change marked the drive into the next day and kept it off a phone stage, but it
only caught the connection `connectDays` builds through `inboundAt`. A trip with a day
window set never takes that path: the window switches `inboundAt` off and builds the days
itself, so day 2 opens on an automatic night standing where day 1 stopped and its first
leg is the drive on from there. That leg is the same connection, it just arrives as one
of day 2's own legs under day 2's number, and the stage drew the whole way back to
yesterday's last stop on today's map. The setting is not even involved: with a window it
happens either way.

So the leg out of an opening night is marked as a join as well, and the phone drops it
with the rest.

One exception, because the filter must not empty the screen: a day that is nothing but
driving, night to night with no stop of its own, has only the connection to show, and
there the line IS the day. When dropping the joins would leave a stage with no line at
all, it keeps them.
2026-09-17 13:55:56 +02:00
Maurice c0ff4dabb3 fix(roadtrip): a phone stage no longer draws the drive into the next day
"Connect the days" is a whole-drive setting: with it on, the leg from one day's last
stop to the next day's first is drawn in the colour of the day it leaves, so the desktop
map reads as one continuous drive across every day. The phone has no such view by
default. It shows ONE day, and the stage filter went by day number alone, so that
connection came along: a line running off the day on screen to a place it never visits,
and on this trip the longest thing on the map by far.

The assembler now says which lines are connections (`lineJoins`, parallel to `lines` and
`lineDays`) rather than leaving each surface to guess, because the day number cannot tell
them apart: it reads the leaving day for both a day's own legs and for the drive out of
it. A stage drops them; the all-days view keeps them, which is the view the setting is
for and is one tap away on the same map, next to the day chips.

Routes read back from an older cache carry no marks, so nothing is known to be a join and
nothing is dropped: those days draw exactly as they did before.
2026-09-17 13:48:18 +02:00
Maurice d07aa80093 fix(roadtrip): the stage map shows its own night pause, and only that one
The moon pill marking where a travel day ends is back on the phone, filtered to the day
on screen. The markers are built from the whole drive, which is right for the desktop
rail and for the all-days view, but a stage draws one day: unfiltered, day 1 carried day
2's pill as well, sitting in the middle of a map that is otherwise entirely day 1. Two
ends on a map that draws one is a question rather than a label.

Off a stage the list is left alone, because in the all-days view every night does belong
to a line that is drawn. The plan tab is unaffected either way: its vias come from the
route calculation, which has no night pauses in it.

This is what the map lost when the pills were dropped altogether: on a day whose drive
ends somewhere other than its last stop, the pill is the only thing that says where.
2026-09-17 13:31:43 +02:00
Maurice 911f3e2583 fix(roadtrip): the phone chain drops the placeholder leg into an automatic night
`dayWindow.stationary()` puts a leg between the last stop of a day and the automatic
night that closes it: same coordinate at both ends, nothing measured, no texts. It is
bookkeeping, not a drive. Drawn as a pill under the last stop it said "No route" — and
before the template was guarded, a bare " in " — under a place the traveller had simply
arrived at.

Matched on the shape rather than on the next stop being a night, because what makes it
not a drive is that it goes nowhere: a day ended at a boundary dragged down the road HAS
a leg with a distance, and that one keeps its pill. A leg nothing has routed yet keeps
its row too, so it can still say it is pending.
2026-09-17 13:26:10 +02:00
Maurice 0b1ca1c98a feat(roadtrip): switch a stop between a pause and a destination from the phone chain
The desktop rail turns a place on the drive into a service stop by its own disc: click
the 3, pick the pump, and the 3 becomes an orange disc while everything below it
renumbers. The phone chain drew the same disc but it did nothing, so the only way to
mark a petrol stop was to open the trip on a computer.

The disc is now the control here too, for the same reason it is there: it is exactly
what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the
desktop's popover under the disc, because a palette aimed at an anchor would land under
the thumb that opened it. The kinds stay discs rather than seven rows of words, each
named for a screen reader. A second tap on the kind a stop already is takes it back to
a destination, and once there is something to undo the sheet says so in words too.

It writes through planner.setRoadtripStopKind, the very call the rail makes, so the
chain redraws off the store with no second code path. Without place_edit there is no
handler and the disc is a plain disc, not a disabled-looking control.

A leg pill with no figures in it is fixed on the way past: a segment that reaches the
chain before its routing round has landed carries metres and seconds but empty texts,
and reading only the texts printed the template with both slots empty, so the pill read
" in " and said nothing. The numbers are formatted when the texts are missing, which is
what the rail does with the same segment, and with neither the leg says it is pending.
2026-09-17 13:10:28 +02:00
Maurice a250fa6339 fix(roadtrip): the phone map loses the stage bar over the dock
It named the day's destination with its picture, its stop count, its arrival and the
stage distance, and it stood there for the whole of the road trip tab. The day and
the distance are in the shell's stage header already, and on a map that fills the
screen a permanent card over the bottom edge costs more than the three facts it
added. Tapping it opened the destination's stop sheet; the chain opens the same sheet
from the row that names it.

The slot stays for the one bar that earns it: while other ways of driving a leg are
on offer, the picker's bar stands there. That wrapper is now mounted with the bar
rather than lying empty over the map's own buttons the rest of the time, and the map
floor lifts only while it is up, so on the stage the round controls stand on the dock
like everywhere else.
2026-09-17 12:58:09 +02:00
Maurice 50f0641ad9 fix(system-notices): a notice waits rather than showing its own key names
The keys come from the server's registry, the copy from the bundle in the browser,
and a deploy parts the two for as long as it takes the page to reload: the service
worker keeps serving the old app while /api/config already reports the new version,
and the release notice for that very version arrives naming keys the old bundle
never had. What is drawn is then system_notice.release_notes.headline where the
headline belongs, on the one notice whose whole job is to be read once per release.

Worse, a dismissal in that window is a real one. The entry is per-version, so
whoever closes the gibberish never sees the notes for that release at all.

So a notice made only of unresolved keys is held back until the reload App.tsx is
already waiting for. Nothing is spent by waiting: a notice is used up by
POST /dismiss and by nothing else. A release notice is judged by the strings its own
two-column layout draws, not by the generic title and body it never shows. Copy
written out in words rather than keyed is left alone, and one resolved string is
enough to show a notice: a notice hidden by mistake is the worse failure.
2026-09-17 12:43:53 +02:00
Maurice 782bb365b5 fix(maps): the phone map carries no credit and no night pause labels
The credit was a round (i) under the locate button, and on a full-bleed phone map a
lone control there is the first thing the eye lands on. It is off on the phone now:
one rule in mobile.css against both engines' containers, so Leaflet, MapLibre and
Mapbox cannot drift apart. The desktop map is untouched and still shows the full
credit with its links. Owner's call, made knowingly.

With the credit gone the band the round controls floated on had nothing under it to
clear, so the 38px credit row comes out of --bottom-nav-h and out of the camera
padding the offered roads are framed with. The controls now stand on the map floor.

The night pause pills go with it. On the desktop one is a drag handle with a hint;
a phone has neither hover to explain it nor room to drag, and the markers come from
the whole drive rather than the stage on screen, so day 1 drew day 2's pill as well.
Both via lists go through the same filter, because the plan tab carries them too.
2026-09-17 12:43:45 +02:00
Maurice 222313d198 fix(roadtrip): closing offers or other roads no longer flies the phone map back to an old stop
A final pass over the feedback commits found two gaps in the phone map.

The planner keeps a point it was asked to show until its next routing
round, and once the fuel offers or the other roads for a leg close it hands
that very array back. MMapArea read it as a new focus, so closing either one
flew the camera to the stop "Show on map" had pointed at, on another day's
stage too. holdFocus now remembers the arrays it already held and only
takes a new one as a focus, with the open picker's own roads as the one
exception.

The all days view still took its pins from the planner's planned list,
which on a phone leaves out booked nights and hidden service stops, so the
drive ended at a hotel with no pin to open it from. It now reads the places
of the stored visits, like the stage does.
2026-09-17 00:36:57 +00:00
Maurice 6a6c7f1882 feat(roadtrip): other ways to drive a leg, on the phone
The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
2026-09-17 00:10:51 +00:00
Maurice 9b415c8ab9 feat(maps): the phone trip map keeps its credit in the bottom right corner
The credit button sat in the middle of the round controls band, beside the
locate button. On the trip map it now has the bottom right corner to
itself, a gap above the dock (or above the stage bar on the road trip
tab), and the band floats one credit row higher, so the switcher, the
compass and the locate button stay on one line.

MMapArea marks its layer as a credit corner and defines the floor the map
ends at; mobile.css places both GL engines' compact control there, keeps
an opened GL credit's button in the corner, moves the Mapbox wordmark into
the same row instead of under the compass, and unfolds the Leaflet credit
to the left of its button. MapAttributionToggle reads the same two
variables with today's values as the fallback, so the other phone maps and
the desktop keep their placement.
2026-09-16 23:21:40 +00:00
Maurice bf0ec85372 feat(roadtrip): badges and a place picture on the phone stage bars, a compact head card
The stage bar over the road trip map joined its figures with middle dots
and led with a bare day colour dot. It now leads with a picture of the
place it names (PlaceAvatar, falling back to the category icon), ringed in
the day colour while day colours are on, and shows the stop count, the
arrival and the distance as badges. The header pill over the tab does the
same with the day and the distance, and leaves out a distance nothing has
measured yet instead of printing 0 m. Both bars carry an accessible name
built from their parts, because pills side by side lose the spaces between
them when a screen reader reads them out.

The badges are a new non interactive MBadge, a span, since every place it
sits in is itself a button. The value rules shared by both bars live in
stageBadges.ts.

The head card at the top of the chain is more compact: the drive and the
stop count ride on the date line as badges, the two clocks are a size
smaller and the separate stats band is gone. The day colour dot is gone
from it: on the list half the line it keys lies hidden under the chain, so
it read as an unexplained status light.
2026-09-16 23:21:40 +00:00
Maurice 9de18634fe fix(roadtrip): a place tapped on the phone stage map opens its stop
The bar over the road trip map names a place, but a tap on it switched to
the whole day's chain and the place never opened. It now opens that stop's
sheet, the one its row in the chain opens. Its name, clock and tap come off
one row (stageEnd), so the bar no longer pairs the destination with the
automatic day end's clock (Kyoto Station 22:00 while the head card said
12:40). The header's list switch stays the way back to the chain.

A pin on the stage went through the plan tab's marker click, which moves
the place selection and brought up the plan tab's place card. On the road
trip tab a pin now opens its stop on the stage card, or the first routed
visit in the all days view, and only a place no routed day stops at still
gets the place card. The handler keeps one identity for the life of the
map. "Show on map" in the stop sheet and the up next card move the camera
through focusRoadtripPoint instead of the selection, picking the stop's
card first when it is not on screen. A pending focus now lets go once the
day moves, so later stages frame their own drive again.

Stage pins come from the trip store instead of the plan tab's map list.
That list carries the plan tab's day declutter and place filters, so after
the all days switch and back most pins of the next stage were missing, and
the unplanned filter or hidden service stops removed stops the chain draws.
2026-09-16 22:49:44 +00:00
Maurice 5b60dda150 feat(roadtrip): edit a stop's place from its phone sheet, title centred on the disc
The stop sheet had no way into the place, so a fixed time could not be set
from the road. A pencil in the header now hands over to the place editor
the plan tab uses, with this visit in context, so Start (the time the chain
anchors on) can be set or cleared there. It shows with place_edit, once the
place row resolves from the whole trip store and the visit carries a server
id. No second writer for the time is added.

Opening the editor on a booked night or a service stop the day lists hide
used to miss the visit, prefill Start from the pool place and write that
over the pin on a plain save, dropping the note too. MPlaceEditSheet now
resolves the visit from the stored assignments.

The header row is centred, so a one line name no longer hangs from the top
of the 34px disc.
2026-09-16 22:23:01 +00:00
Maurice bf03d91345 feat(roadtrip): the phone dry band gets the desk's mascot and reserve lamp
"Tank runs out here" on the phone was a pink card with a black pill. It now
has the desktop band's design sized for a thumb: the mascot on its
skateboard and not enjoying it, a small uppercase warning, and the reserve
lamp as a 44px button. While a search runs or its offers show, the lamp
steps aside for a close (nothing in the band could clear the offer pins
before), and an empty answer leaves a retry.

Which control shows, which offers and which empty sentence is decided in
refuelBandState in refuelSuggestion.ts, next to the fuel and battery words.
The desktop RefuelBand reads the same helper now, with no visual change, so
the two shells hold markup only.
2026-09-16 22:23:01 +00:00
Maurice 79450a5380 fix(roadtrip): head the phone stage with its start, not the first stop's departure
The head card read LEAVE 11:30 over a chain whose first stop is pinned at
10:00 with an hour and a half there. The figure was the departure from the
first stop, a clock no row prints, while ARRIVE was the last stop's arrival.
So the number was the wrong quantity and "Leave" the wrong word for it.

Both figures now come off the arrival column the rows print: START is the
first timed arrival (or the resume point on a morning after an automatic
night), ARRIVE is the last one. The rule lives in stageClocks in the shared
row model and reaches the tab as rt.clocks. The label is a new key,
mobileTrip.rtStart, because roadtrip.stay.leave keeps meaning a stop's own
departure in the stop and stay sheets. Both clocks carry dir=ltr like the
rows.
2026-09-16 22:23:01 +00:00
Maurice bd347f553b fix(maps): the satellite switch answers on the phone, and the compass stops hiding under it
Two faults behind one round button on the phone map.

The base layer switcher that both engines draw in the bottom left corner
sat on top of the GL compass, which started 8px to its left and ran on
under the frosted shell, so it read as a second button showing through.
The compass now stands beside the switcher, placed off the switcher's own
exported inset and size so the two cannot drift apart again.

Satellite never drew on GL because applySatellite gave up whenever
isStyleLoaded() was false and only retried on styledata. That flag is false
while any source has tiles or a setData in flight, which on a phone is the
usual state, and styledata does not fire when those finish. On the road
trip stage every tap met a busy style, because the settings write
re-rendered the shell and handed the map fresh stage arrays. The guard is
gone (the pass only runs after load, and addSource/addLayer only refuse a
style document that is not in yet), an existing layer just flips its
visibility, and a source left without its layer still gets the layer.

The stage data in MMapArea is memoised, so a store write no longer refits
the camera or sets the route, access and place sources again.
2026-09-16 22:22:46 +00:00
Maurice aaff8f9db4 feat(roadtrip): bring the road trip addon to the phone
A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.

The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.

The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.

The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.

Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
2026-09-16 21:55:18 +02:00
Konstantinos Thermos 5f57c63426 fix(journey): make the source_assignment_id migration replay-safe
An unreleased migration has to survive being replayed on a schema where it
already applied — installs skip releases, and a test rewinds a fully migrated
database to the last released version to prove it. The bare ADD COLUMN threw
"duplicate column name" on that replay, which took every migration test down
with it.

Guard the ALTER on pragma_table_info, and add `source_assignment_id IS NULL`
to the backfill so a replay cannot re-resolve a row that has since followed
its stop to another day.

The trips e2e harness hand-builds a minimal journey_entries table rather than
running migrations, so it needs the column spelled out too.
2026-09-16 16:32:02 +03:00
Konstantinos Thermos bd44197974 Merge pull request #2330 from liketrek/feat/pdf-trip-route-map
Feat/pdf trip route map
2026-09-16 16:10:31 +03:00
Konstantinos Thermos 37163e9a41 fix(places): render markdown in the mobile place description
The mobile shell printed place descriptions and notes as plain text while
the desktop rows and inspector run the same field through react-markdown,
so a description written with the formatting bar read as `# headings` and
`**asterisks**` on the phone.

Put the renderer in one shared component instead of copying it next to
each mobile twin, with a clamped variant for list rows that keeps them one
line high the way the desktop sidebar does. A clamped caption sits inside
the tappable row, so its links keep their text and lose the anchor.

Fixes #2337
2026-09-16 15:42:07 +03:00
Maurice 61a8a2765b fix(geo): read the Nominatim override the way the schema validates it
Follow-up to #2379.

NOMINATIM_URL arrives from a compose file or a ConfigMap with whatever
whitespace was typed around it. The schema validates the trimmed string and
calls a blank one unset, the derivation did neither, so " " passed startup as
"use the default" and then became the base URL that cannot be fetched, and a
padded value lost every geocoding call the same way. It trims now, which is
also what the places client next door does with its own override.

The chart could not pass the variable at all: the ConfigMap is an allowlist and
an unknown key is dropped without a word, which is exactly the locked-down
cluster the override exists for.

The docs said what to set and not what it costs. They now say that it replaces
the public service with no fallback, that the endpoint has to speak the
Nominatim API, and that the one-per-second spacing stays either way, so nobody
stands up their own instance expecting it to run faster.

Test IDs follow the file again.
2026-09-16 11:08:07 +02:00
Luffy c0195557d4 feat(geo): support overriding Nominatim endpoint via env 2026-09-16 14:30:31 +08:00
Maurice 06610bd97e Merge pull request #2377 from liketrek/feat/release-notes-430
feat(notices): bring the release notes back on every update
2026-09-15 23:51:17 +02:00
Maurice 05ee3d50e4 feat(notices): bring the release notes back on every update
The release modal takes the 4.3.0 design: the release and its headline cards
on the left, the note and the support links on the right, at the design's own
size. The cards carry small drawings of the features instead of an icon.

The 4.0.0 entry is retired. In its place sits one entry, release-notes, that
every big release rewrites and keeps. It recurs per version with no upper
bound, so every user sees it once on each update, whatever they did with it
before, and a reload of the same version keeps it closed. An integration test
holds that promise through the real entry and the real dismiss route.

All 23 locales carry the new copy and drop the 4.0.0 strings.
2026-09-15 23:50:15 +02:00
Maurice e2bdd2dd08 Merge pull request #2362 from liketrek/dependabot/npm_and_yarn/js-yaml-4.3.2
build(deps): bump js-yaml from 4.3.0 to 4.3.2
2026-09-15 23:11:57 +02:00
Maurice 99783cc8da Merge pull request #2346 from WenyinWei/fix/share-map-clustering
fix(shared): cluster the public share page's markers
2026-09-15 23:06:41 +02:00
Maurice e5d85eda8d Merge pull request #2347 from WenyinWei/fix/share-header-overflow
fix(shared): keep the header's decoration from widening the page
2026-09-15 22:58:10 +02:00
Maurice a9cd47caeb build(deps): bump js-yaml from 4.3.0 to 4.3.2
Only the js-yaml entry changes. The Dependabot commit was built on main and rewrote the lockfile with another npm, which dropped the peer flag from 23 rollup entries and carried two wiki commits that dev does not have yet.
2026-09-15 22:49:10 +02:00
Maurice 8a72d98bc6 test(shared): type the cluster icon size check and tighten the comments
iconSize is a PointExpression, which has no x on its tuple form, so the client typecheck refused the size assertion; it now compares against L.point. The test ids follow the FE-PAGE-SHARED-MAP scheme in the order they run, and the comments around the cluster group say in a line what they said in a paragraph.
2026-09-15 22:47:46 +02:00
Maurice 09e46bf723 test(shared): give the header clip test an id of its own
FE-PAGE-SHARED-040 already names the trip-wide marker colour case in the same file, so the new case moves to 042, and the comment above the clip says what it needs to in two lines.
2026-09-15 22:41:37 +02:00
Maurice f0a4e4c74d Merge pull request #2376 from liketrek/fix/atlas-visit-dates
fix(atlas): date a country's first and last trip from the trips that count
2026-09-15 22:05:34 +02:00
Maurice acd6f1acdf fix(atlas): date a country's first and last trip from the trips that count
The Atlas tooltip took both dates from the start dates of every trip with a
place in the country. One trip showed the month it began twice, the month it
ended never appeared, and a trip only booked could become the last visit of a
country already visited.

Both now come from the trips that give the country its status, places and
bookings alike: the start of the earliest and the end of the latest, with a
trip still under way ending today. The optimistic mark on the client clears the
dates the same way the server does.

The second label rendered the raw key atlas.lastVisitLabel since #1908 removed
it from the locales; the strings are back as they were. The month is built
from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and
showed the month before anywhere west of Greenwich.

Closes #1535
2026-09-15 21:36:34 +02:00
Konstantinos Thermos 48cd46f818 feat(pdf): put the trip's route map in the export 2026-09-15 15:58:36 +03:00
Konstantinos Thermos 4eaf90fd1b fix(journey): a place kept across two days is two days of the journal
The sync engine keyed a skeleton by source_place_id alone, so a stop standing
on two days — the city you land in at dusk and walk through the next morning —
got one entry on the first of them and nothing on the rest.

Key on the day assignment instead. The assignment row rather than the day,
because moving a stop updates day_assignments.day_id in place: keyed this way
an entry follows the move instead of reading as one stop leaving the plan and
another arriving. Existing rows are backfilled to the place's earliest
assignment, and reconciliation adopts an unlinked row rather than annotating
it out from under its author.

Fixes #2329
2026-09-15 15:31:59 +03:00
Maurice 2efa843306 fix(test): the MCP endpoint cases find their own row again
FE-COMP-INTEGRATIONS-004, -015 and -016 took the first <code> and the first
Copy button on the tab and expected the MCP endpoint. Since the API keys card
shows the /api/v1 address with a copy button of its own (#2371), and it sits
above the MCP section, those queries landed on the API card: the URL read
/api/v1 and the click copied it. The cases now go through the endpoint's own
label.
2026-09-14 23:23:22 +02:00
Maurice 53eeba3c1b Merge pull request #2372 from liketrek/fix/dawarich-tester-feedback
fix(dawarich): three things a tester on dev1 ran into
2026-09-14 23:09:33 +02:00
Maurice c1d55c463c fix(dawarich): three things a tester on dev1 ran into
The Atlas country check timed out. It asked Dawarich for a year of visited
cities in one request, and Dawarich computes that over every point in the
window with nothing cached, so a dense recorder never got under the 15 s the
server allows. The year is now asked for a month at a time, one request after
another, and folded back together. The windows sit a second apart because
Dawarich reads the bounds as inclusive.

The recorded route only appeared after a reload. Every Dawarich call from the
browser carried the shared 8 s timeout, so the browser gave up while the server
carried on and cached the answer for the next page load. Calls that make the
server talk to Dawarich now wait two minutes, the Atlas four. On the GL
renderers the draw also gave up whenever isStyleLoaded() was false, which it is
while any tile is still loading, and only retried on style.load. It now waits
for idle. The hazard overlay had the same guard and gets the same wait. The
pill shows a ring while the route loads, so a slow first fetch no longer looks
like "done, nothing there".

A collapsed day now takes its recorded route off the map, following the same
folds the planner already applies to places, the road trip sidebar's included.
2026-09-14 23:00:57 +02:00
Maurice 60cbb41d5f fix(roadtrip): put a corridor hit on the drive from the day before ahead of the first stop
The corridor searches the whole line a card draws, and a card that opens
with a drive from the day before (a night carried over, or the joined road
on connected days) draws that drive ahead of its first stop. The insert
index still came from insertIndexForAlong, which never answers 0, so a
charger found on that stretch was added after the first stop and the day
drove there, turned back to the charger and came the same way again.

insertIndexFor now answers 0 for a hit before the first stop when the card
arrives from an earlier stop, and dropping a hit on the map asks it instead
of computing its own index, so a click and a drop cannot disagree.
2026-09-14 22:43:46 +02:00
Maurice 2ffe4d6054 fix(roadtrip): file a via on the drive between connected days after the stop it leaves
With connected days the drive from one day's last stop to the next day's
first is drawn at the head of the arriving card, in the colour of the day
before. anchorFor only knew that stretch as a night spill, so a click on it
anchored to the arriving card's first stop: the via was stored on the leg
after that stop, and the next day drove out to the point, turned and came
back, while the stretch that was clicked kept its shape.

assembleRoadtrip now names the stop that joined drive leaves from, and
anchorFor treats it like the stop a night drive left from. Placing and
dragging both resolve to the previous day's last stop, which is the anchor
the seam request already reads its vias from.
2026-09-14 22:36:29 +02:00
Maurice 1b49995c15 Merge pull request #2371 from liketrek/fix/api-keys-settings-polish
fix(settings): bring the API keys section and its dialog in line with the rest of TREK
2026-09-14 22:05:57 +02:00
Maurice 25282ea1c5 fix(settings): bring the API keys section and its dialog in line with the rest of TREK
The section stacked a lonely button, a centred line and a small print hint
on top of each other, and the create dialog used bare browser checkboxes
that ignore the accent. The dialog now runs on the shared Modal with
selectable scope tiles, keys render as cards with their scopes as chips,
deletion goes through ConfirmDialog, and the endpoint sits in its own row
with a copy button.

Pressing Enter in the name field with every scope switched off used to
create a key: the empty list was dropped from the request and the server
stored it as a key that reads everything. Creation is now refused until at
least one scope is picked.

A failed list load no longer reads as "no keys yet", the create button
stops at the server's limit of ten keys, a refused clipboard says so, and
SQLite timestamps are parsed so Safari does not show Invalid Date.
2026-09-14 21:59:27 +02:00
Maurice da679e6266 feat(roadtrip): add a stop by hand in the form the rest of TREK adds places in
The dialog built for this last round was its own thing and showed it: the
suggestion list was barely legible, clipped at the bottom, and never more
than two rows tall. It also lacked most of what the ordinary add-place form
has had for years.

So it is gone, and "Add manually" opens that form instead, in a service-stop
mode. The category control becomes the kind of stop and the time spent
there, the costs section is dropped, and an "Add between" row says where on
the drive it goes. Everything else comes along rather than being rebuilt:
the typed-ahead search with its source marks, the coordinate fields for a
charger no index knows, address, website, phone, notes, times, files and the
duplicate check. With the mode absent the form is the same component it was,
which its own suites still pin.

Where the stop lands is worked out when it is saved rather than when the
form opens, because the coordinate only exists once a place has been chosen.
One function answers both what the select shows and where the stop goes, so
the two cannot disagree, and the write goes through the same assignment path
as every other insert, via re-anchoring included: a stop pushed into a day
shifts every via anchored at or after it, and without re-anchoring first the
drawn road runs forward, doubles back and runs out again.

The select recommends rather than merely offering. Every leg of every routed
day is measured against the place separately and they are listed nearest
first, each with its own distance, so the preselected one visibly is the
closest and overruling it is an informed choice. The word they all shared
moved into the caption.

A stop with no position at all is not filed on a drive by guesswork. It
keeps its kind and goes to the trip's places, and the form says so and says
how to change it.
2026-09-14 16:22:18 +02:00
Maurice 68ce177901 fix(test): CHARGING-013 failed on a date, not on a count
The case asserts no availability counter is shown for an ambiguous station, and
looked for one with an unanchored /\d+\/\d+/. The attribution line beside it ends
in the source's updatedAt run through toLocaleString(), and tests/setup.ts only
forces en-US on toLocaleDateString, so that timestamp reads "14.9.2026" on a
German machine and "9/14/2026" on the CI runner. The regex matched the second
one. Green where it was written, red on Linux, and red for a reason that has
nothing to do with charging.

Anchored at the start instead. Both places that print a count begin with it, so
the case still says what it meant to say.
2026-09-14 14:26:15 +02:00
Maurice 3841412b22 fix(roadtrip): the two search actions stop cutting their labels in half
"Add manually" did not fit beside "Search" and rendered as "Add manu…",
which is neither the label nor a shape anybody recognises.

The button face now carries one short word, with the full sentence kept as
its accessible name and its tooltip, so nothing is lost to a reader or a
pointer. Every locale got its own short form rather than a clipped version
of the long one: Selbst, A mano, Ručně, Elle, 手動.

Below two hundred pixels the row drops both words instead of shortening
them, and the two actions are their icons. The width is the row's own,
measured, not the window's: this column is resizable, so it can be narrow on
a wide screen and a media query would answer the wrong question.

The search button also stops restating the progress. Its label used to grow
into "Searching 3 of 12" while a run was on, which is the longest string in
the panel and the very sentence the progress row directly underneath already
prints next to the share as a figure. It keeps its word, disabled, with the
spinner in front of it.
2026-09-14 13:50:44 +02:00
Maurice 049fb76965 Merge pull request #2366 from liketrek/fix/roadtrip-feedback
feat(roadtrip): the testers' round, and a defect it uncovered
2026-09-14 13:33:36 +02:00
Maurice 54b94cf9e3 docs(wiki): the manual stop, clearing a search, and charging before the add
Three panel changes and one new MCP tool that the pages did not describe
yet. Also records the two rules the code enforces and a reader would
otherwise have to discover: the charging panel is fetched once per opened
dialog rather than per row of a result list, and the lookup tool carries no
power rating or socket count because those are OpenStreetMap tag data that
travels with the corridor result instead.

The wiki syncs on a push to main, so this rides along until the next
release rather than appearing now.
2026-09-14 13:22:26 +02:00
Maurice b058b63f6e fix(roadtrip): the manual-stop dialog looped on a hint rebuilt per render
Three things the first CI run found, all of them in the new tests rather
than in what they cover.

The dialog's search effect depended on the location hint object. Its own
hook memoises, so nothing looped in the app, but the effect sets state on a
query too short to ask about, and a caller whose hook hands back a fresh
literal per render turns that pair into a render loop that never yields. The
suite mocked it exactly that way, and the worker hung before a single test
reported, which is what took the client coverage job past half an hour. The
hint is three numbers, so the effect now turns on those and reads the value
out of a ref when the request goes out. The mock hands back one object, the
way the real hook does.

The suite also installed fake timers in a beforeEach, which deadlocks
against React's scheduler and Testing Library's own waiting. Every wait is
a waitFor now, and the two cases that have to prove something did not
happen sit out a real pause. The debounce boundary is no longer asserted
against a wall clock, because a busy runner stretches any such window.

The charging fixtures are pinned to a fixed instant and the service reads
the real clock against a twenty-minute freshness window, so every answer
through it was stale by construction and available came back null. Date.now
is pinned for those tests, never the timers, since the requests carry an
AbortSignal.timeout that has to keep running.

And the unidentified-station test counted its two "no reliable data" lines
before the answer landed: the price line already says it while the request
is out, so the count settled on one and passed for the wrong reason.
2026-09-14 13:20:21 +02:00
Maurice f24827e7ed Merge pull request #2365 from liketrek/test/dawarich-coverage
test(dawarich): the integration that shipped without a suite
2026-09-14 13:02:35 +02:00
Maurice 00d00be1c0 feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked
for it while still deciding. The availability and the published tariffs were
reachable only for a saved place: the route takes a places id, and a hit
found along the route has no row yet.

ChargingService.load already needed nothing but a coordinate and a name, so
it now has a public method that says so, behind a trip-scoped route with the
same guard stack, a Zod contract in shared, and the parallel MCP tool in this
same change. Same service, same caches, so a station looked at in the dialog
and then added keys the entry already fetched. Trip-scoped even though the
trip is not part of the question: without :tripId the trip access guard goes
too, and what is left is an open proxy in front of somebody else's public API.

It renders in the add dialog, and only while the selected kind is charging,
so a fuel or food stop costs nothing and one opened dialog is one lookup.
Deliberately not per result row: the corridor search publishes box by box,
every mounted panel owns a refresh timer, a cache miss can cost seven
upstream requests, and a station in a dense charging park is the case the
upstream matcher refuses as ambiguous.

Power and socket counts are not in this answer and were not asked of it.
They are OSM tag data and already ride along on the corridor row for free.
2026-09-14 12:28:47 +02:00
Maurice accdd5905e feat(roadtrip): add a stop the search never found, and clear the ones it did
Four things testers asked for in the "Along the route" panel.

Add manually. The corridor search reads OpenStreetMap, and a good share of
the chargers standing at a motorway junction are not in it, Tesla
Superchargers among them. The way round it was to leave road trip mode, add
the place under Days, drag it onto the right day, come back and mark it a
charging stop. The Search button now shares its row with a second one that
asks the two questions those four steps answered: which place, and where on
the drive. The place comes from a debounced typeahead over the place search;
the position from anchorFor, the same projection a via is placed with, and
it is offered as a dropdown of every leg of every routed day so it can be
overridden. Unlike a via, a place far from the drawn line is not refused,
only flagged: that place is the reason this dialog exists. From there it
hands over to the stop popup a corridor hit already goes through, so the
kind and the dwell are decided once for both paths and the write stays
saveStopDraft.

Clear results. A search that turned up the wrong thing left its hits in the
list and its pins on the map, and the only way out was switching to the day
plan and back. The search hook has exposed a clear() since it was written
and nothing ever called it. A control in the results header does now, and
takes the four narrowing states with it, because they were set for a
question that no longer has an answer.

Charging as the default for an electric car. "Looking for" opened on Fuel
whatever the vehicle. It opens on Charging when the trip's vehicle is
electric, and is unchanged for combustion, for an empty value and for a
vehicle nobody set, so nothing moves for anybody who never opened the
dialog. Seeded once, never over a category the traveller picked or a search
in flight.

Clicking a result moves the map to it. The row had no click handler at all;
only the trailing Add button did anything. The badge and the text are one
activatable target with that button as its sibling, which keeps the li a
list item and avoids a button inside a button, and it goes through the same
focusRoadtripPoint the rail uses, which closes the refuel and alternatives
panels first because those outrank the camera.
2026-09-14 12:28:30 +02:00
Maurice f4bbb0fe5d fix(roadtrip): the alternatives list compared two routers' drive times
The ways of driving one leg come from OSRM, except the toll-free or
motorway-free offer beside them: the shipped OSRM hosts are built without
excludable road classes and answer exclude= with HTTP 400, so that one is
asked of the avoidance router instead.

Their figures were then read against each other. The quickest was elected
across both, which is what decides the road drawn as the one being driven,
and every other road was labelled with how far behind that election it sat.
The two engines agree on the road to a tenth of a percent and not at all on
how fast it may be driven: over twelve European legs the per-leg time ran
from 14.7 % under OSRM's to 13.2 % over, with the sign following the region.
On a Spanish motorway leg that gap alone is enough to crown a toll-free
detour and call the motorway somebody is on the slow way round.

An offer now carries the engine that timed it. It takes no part in electing
the quickest, no difference is printed across the two, and the bar says where
the figure came from. Each route still reports its own time; only the
subtraction is refused. An instance running its own OSRM through the MLD
pipeline answers exclude itself, so there both figures are one engine's and
the comparison works exactly as before.
2026-09-14 12:28:12 +02:00
Maurice a6e8c66609 test(dawarich): the integration that shipped without a suite
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.

What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
2026-09-14 10:39:49 +02:00
Maurice b2384a7523 test(places): the delete doubles answer in the shape the service now uses
remove and removeMany report what the nights they cancelled took down with
them, so the stubs in the controller and plugin-RPC suites report it too. Plus
a case for the announcement itself, which had none: place:deleted says nothing
about a partner booking or an expense linked by reservation_id.
2026-09-13 23:49:01 +02:00
Maurice 8cddd445ba test(roadtrip): the assemble spec holds up under shared's stricter typecheck
noUncheckedIndexedAccess makes every index access optional, DriveLimits wants
its two minute ceilings named even when they are null, and the distance unit is
metric/imperial rather than a unit label.
2026-09-13 23:49:01 +02:00
Maurice 39d832c772 test(journey): the entry picker takes clips, so the test may not insist on stills
FE-PAGE-JOURNEYDETAIL-105 still looked for accept="image/*" on the editor's
file input after it learned to take video (#2341), which is the one red test on
dev. The camera input beside it stays image-only and the test says why.
2026-09-13 23:49:01 +02:00
Maurice f4380727c7 fix(roadtrip): the range warning and the per-leg figures in the server plan
deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.

The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
2026-09-13 23:49:01 +02:00
Maurice 61880ce7d6 fix(roadtrip): the vias, the alternatives, the pacing and the missing warning
Choosing another road on a leg that already carried a via appended the new
point behind the old one, so the day drove south to the first and north to the
second: a zigzag matching neither the line drawn under the button nor the
distance printed on it. The alternatives are computed for the two bare
endpoints, so the write has to replace the leg, which is what the direct branch
beside it already did.

A day's vias were only loaded while road-trip mode was being looked at, and the
three handlers that re-anchor them after a stop is added, removed or reordered
are reachable from the place inspector in both modes. With the switch off they
computed their correction from an empty list and wrote nothing, so a deleted
stop left the detour on a leg nobody chose, for the traveller and for every
collaborator. They are loaded whenever the addon is on now.

Seam routing paced itself by sleeping after each request, and storing a result
shortened the key its own effect depends on: React tore the effect down
mid-sleep, the cleanup aborted it, and the next request went out immediately.
Thirteen seams left at round-trip speed, the shared routing hosts refused the
tail, and a refused seam changes no state so nothing ever retried it. The wait
now happens before the request, against a clock that outlives the effect.

The "avoidance not honoured" badge could never appear on the rail: the field
behind it was published every round and never written. Only the second engine
reports what it achieved against what was asked, and the rail plans in the
browser, so it never saw the server's own copy — a motorway crossing read as a
motorway-free drive.

Two smaller ones. `trip.toast.loadError` has been called since the first commit
and exists in no locale, so both call sites printed the key itself; it is
written now in all 23. And the phone shell honoured the road-trip session flag
while having no rail, no drive lines and no switch to leave it, so narrowing a
desktop window took the trip overview away and listed every booked night twice.
2026-09-13 23:49:00 +02:00
Maurice c8cb88133f fix(maps): the projection, the labels and two unbounded reads
A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.

Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.

Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
2026-09-13 23:49:00 +02:00
Maurice 9deedb80ea fix: receipts, cached places, chat previews and the Google-route tool
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.

The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.

The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.

import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
2026-09-13 23:49:00 +02:00
Maurice f0e4c9a33d fix: five small defects the audit turned up in the release
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.

update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.

The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.

A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.

An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.

Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
2026-09-13 23:49:00 +02:00
Maurice 89bae414ff fix(offline): a missing driving-settings row no longer discards the trip bundle
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
2026-09-13 23:48:59 +02:00
Maurice c42cf5477b fix(public-api): a key narrowed away from days stops getting days back through places
Places, notes and reservations hang off days, so asking for one of them implies
the day container. That implication ran ahead of the grant: a key minted with
scopes ['trips','places'] and deliberately no 'days' still received every day's
title and the day's own free-text notes, either by naming places or by naming
nothing at all, since an absent include narrows to the granted sections and
lands back in the same branch. Asking for days by name was a 403 the whole
time, so the two routes disagreed about the same fields.

The grant now reaches the service alongside the narrowed include. The container
is still implied, because the places inside it are useless without their date,
but the day's title and notes only go out to a key that was granted days. A key
with full access is unaffected: it is granted days whether or not it asks.
2026-09-13 23:48:59 +02:00
Maurice 54e5525568 fix(accommodations): a booking that moves carries its stop instead of rebuilding it
Correcting a booking's date deleted the day stop it had mirrored and inserted
a fresh one. A day stop is not just a day and a place: its participants and any
road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its
hour and its end-of-day flag live in its own columns. All of it went, silently,
for an edit that only meant to move the night one day on. The row is carried
across now, seated by its check-in the same way an insert would be, and the
delete-and-insert is kept only for the case where the target day already holds
that place under a stop of the traveller's own.

Three more holes in the same mirror, found alongside it:

Dropping a night while keeping the place cleared accommodation_id server side
and told nobody. Days hides a stop that carries a booking id, so on every other
screen the place stayed invisible on a day it was standing on.

A night is seated by its check-in, which renumbers the stops around it, and
nothing said so. The day that changed now sends its order along.

Deleting a place cancelled the nights booked there, which takes each night's
partner booking and that booking's expense with it, and announced neither. The
Bookings list kept the reservation and the Costs total kept the money until a
reload, and an expense linked by reservation_id is not one the place-linked
sweep finds.

Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening
a trip past a booking's last night cascaded the booking away and left the stop
pointing at nothing, hidden from Days for good while the route still drove to
it. Migration 234 clears the ones already out there and adds a trigger, which
SQLite fires even when the booking went down with a cascade. And a duplicated
trip now carries the stop's booking id, the booking's check-in window and a
place's fill_percent, none of which the copy took along.
2026-09-13 23:48:59 +02:00
Maurice 0cc6dcf8aa Merge pull request #2360 from liketrek/fix/oidc-exchange-binding
fix(oidc): bind the SSO auth code to the browser that earned it
2026-09-13 22:09:59 +02:00
Maurice a2585beb13 docs(wiki): the SSO exchange takes a cookie as well as the code 2026-09-13 22:05:54 +02:00
Maurice 803a7cb64a fix(oidc): bind the SSO auth code to the browser that earned it
The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.

The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
2026-09-13 22:03:24 +02:00
Maurice 9cb74e22f6 fix(dawarich): a stay's length was sixty times too short
Dawarich's `duration` is minutes. It writes the column as
`duration_minutes = ((ended_at - started_at) / 60).to_i` in
`app/services/visits/create.rb` and serialises it straight through; the merge
service rounds the same way. TREK read it as seconds and divided by sixty
again, so an eighteen-minute stop reached the suggestion panel as "0 min" and
an afternoon somewhere as "2 min".

Found by connecting a real Dawarich 1.14.5 and comparing: a visit spanning
11:00:00 to 11:18:45 reports `duration: 18`.

The span between the two timestamps is what the panel uses now. It agrees with
the arrival and departure shown beside it and it survives whatever unit a
future Dawarich picks; the reported figure only stands in when `ended_at` could
not be parsed, and is read as minutes there too.
2026-09-13 21:38:20 +02:00
Maurice 8eb936f46f fix(test): the places e2e knows the table the delete path reads
Deleting a place cancels the nights booked at it, and that query hits
`day_accommodations`. This file builds its own minimal schema and did not have
the table, so every delete answered 500 and three cases failed. The assignments
e2e was given it for the same reason; this one was missed.

This is the failure that has been keeping Server Coverage red on dev.
2026-09-13 21:25:07 +02:00
Maurice 6e8aa3ab26 fix(dawarich): say why the address could not be reached
The server works out the reason and names it: a rejected certificate, a name
that does not resolve, a timeout, or the SSRF guard refusing a private address
along with the environment variable that would permit it. All of that reaches
the client as `errorDetail`, and the panel dropped it, leaving one sentence that
reads the same whether the host is off or TREK refused on purpose.

A tester could only report that it did not work. The panel shows the reason
under the message now; the toast stays one line, and the vague codes stay vague
so an English sentence from Dawarich does not land on a German install.
2026-09-13 21:19:42 +02:00
Maurice 76f45512d1 feat(admin): the phone's API-keys card, rebuilt like the desktop tab
The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.

So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.

The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.

Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
2026-09-13 21:07:55 +02:00
Maurice c2751d9c80 fix(journey): declare the three field flags on updateJourney
The allow-list learned them and the signature did not, so the only callers that
name them are the tests and only `typecheck:tests` saw it. `typecheck` covers
src alone, which is why this was green locally and red in CI.
2026-09-13 20:44:24 +02:00
Maurice f8ce80da32 fix(mobile): the pills and the badge above them on one ground
Both are white at 28 percent, and they did not look it. The pills carried a
backdrop blur of their own, and a second backdrop-filter inside the panel's own
opens a new backdrop root, so the same colour composited darker one row down.
The panel already blurs everything behind it.
2026-09-13 20:41:14 +02:00
Maurice cbc28fad88 fix(journey): a dead translation key, and two cards that spoke differently
`common.errorOccurred` is in no locale. It sat in the reorder failure toast
already and I copied it into three more places, so a failed dismiss, restore or
reorder printed the key at the reader. They all say `common.errorTitle` now.

The suggestion mark on a phone card was the word spelled out, which took a third
of a 136px card and pushed the date off it; it is the same plus the desktop
suggestion card offers, and the card's italic dimmed title carries the rest.
Dismissing a suggestion from the entry sheet has its own full-width row above
the buttons rather than a two-line label wedged between Delete and Cancel.

And two cards that should have been one design: the dashboard's spotlight trip
wrote its facts in capitals on a panel of dark glass, the journey hero wrote
them in sentence case on a gradient with its badge loose in a corner. The
journey card is built the way the trip card is now, and the trip card's three
pills are set like the DAY x OF y badge beside them, a size down and on the
same ground.
2026-09-13 20:32:49 +02:00
Maurice 15aefa449e feat(journey): the marker card, and what testing it turned up
Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
2026-09-13 19:52:14 +02:00
Maurice c2707b7eff feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or
rest on a misunderstanding, one cannot work as described, and three would take
away more than they give; the rest are here, plus two things the list did not
ask for.

**The entry you write on the move now has a name.** Quick capture hid the title
field outright and offered a note instead, so every entry caught on a phone
arrived nameless and a day's timeline read as a column of identical
placeholders. One line of input; it is the single change in this branch that
users will feel most.

**The map stops yanking.** Stepping through the timeline called flyTo with a
zoom floor of 12, so reading a journey from a country view snapped to street
level on the first scroll and stayed there — every marker filling the screen,
and no sense of where anything was. Both renderers pan now and leave the frame
the reader chose. The initial fitBounds still decides how close a journey opens.

**The phone timeline is photos.** A 64px thumbnail beside three lines of text
spent the card on a story nobody reads at that size and truncated the title
after a few characters. The picture is the card now, with the name and place on
it, the country's flag and the date in the corners, and the day carried as a
colour rather than as a number nobody was reading. Above the carousel, a
segment per day: it says how long the journey is, how far in you are, and takes
you to day nine without swiping past days three to eight.

Everything else, briefly:

- A plus in each day header, starting a draft on that day instead of on today.
- A journey opens on today when today is part of it, and at the beginning when
  it is not — a journey that ended last spring is something you read.
- Suggestions can be waved away one at a time. The row survives so the trip sync
  does not offer the same place again; journey settings brings them all back.
- Mood, weather and the pros/cons list can be switched off per journey. Nothing
  written is erased — the form stops asking, the values stay.
- The weather fills itself in at the desk the way it already did on the phone.
  The date picks the source, so writing up last Tuesday gets last Tuesday's.
- Enter opens the next pro or con, below the one you are in.
- The verdict moved behind the story's fold, and the fold itself came in from
  nine lines to four.
- The external picker lost the count it was printing twice and some padding it
  could not afford inside the editor.
- The phone can reorder entries within a day at last; the desktop arrows finally
  have translated labels.
- Marker labels are off where a carousel already names the entry, and on where
  they are the only name a marker has.
- Search over a journey's titles, stories, places and tags, accent-blind.
- An entry knows which country it happened in, resolved once from its
  coordinates against the bundled boundaries.

Not built, and why: the photos-for-markers idea needs clustering first or thirty
markers become soup, and the active entry's photo is already on screen in the
card below. Removing mood, weather and the verdict outright would take them from
everyone who uses them, so they became switches. `capture` on the file picker
takes the photo library away on a phone, which is why the camera is a second
input; it is hidden on desktop now instead. The suggestion strip in the new-entry
flow would be a third surface over the same data — tapping a suggestion already
opens the editor filled in.

The one gap the list missed and this branch does not close: Journey is the only
domain with no offline path. That belongs in its own change.
2026-09-13 18:45:57 +02:00
Maurice 6edb4462f9 style(collections): the label chip rounded like the rating beside it
Two badges in one row read as one set only if they share a shape. The rating
was a pill, the label a rounded rectangle.
2026-09-13 17:06:35 +02:00
Maurice 27ce4df6f5 style(collections): the place panel as things on their own ground
The labels tried the cover photo and lost: a blue chip on a blue sky is simply not
there, whatever shadow it carries. They sit beside the rating now, both as badges
on one row — two answers about the place, side by side, each legible.

The note takes the full width on a ground of its own. Loose in the column it read
as another paragraph of the panel rather than as something somebody wrote about
this place.
2026-09-13 17:02:17 +02:00
Maurice 1a95157b3a feat(collections): say what a place is without having to click it
Asked for in the feedback: a wall of round photos, and no way to tell which is
which. "Name and displaying any custom rating would be very beneficial to quickly
seeing what is around you that is of interest."

The collections map had hover switched off. The trip map has told you what a
marker is all along, so it is the same card, now shared instead of written a
third time — it already existed twice, once per renderer, and the two had drifted
in how they resolved the category icon.

The card gains a rating, and so does the marker itself: an amber disc on the
corner where a trip's day numbers sit. The two never collide, because a numbered
stop is one already planned into a day and this answers the question asked before
that. Nothing is drawn for an unrated place, which is most of them.

Also from this round: the place panel's labels move up beside the name, where a
label belongs — read three rows down they looked like a property of the rating
above them — and the rating gets a ground of its own so a line of stars stops
reading as another row of text.
2026-09-13 16:49:20 +02:00
Maurice 8fc788fb37 style(collections): the place footer is one row of things you do
Getting directions sat up by the address, in a pill of its own, while editing and
copying sat in the footer as square buttons. All three are things you do WITH the
place, so they belong together and should look like one set. Navigation moves
down beside them, and the footer takes the shape the navigation button already
had.
2026-09-13 16:35:31 +02:00
Maurice de3b2a6ac7 fix(roadtrip): a check-in sets the hour when nothing before it does
Book a night to check in at ten, pin a stop to three in the afternoon behind it,
and the rail said 13:46. True enough as arithmetic — with no hour of its own the
chain works the first stop backwards out of the first one that has one, so three
minus a fourteen minute drive minus an hour standing there — and nothing anybody
asked for.

A door that opens at ten, with nothing ahead of it deciding otherwise, IS the
hour: the day starts there and runs forwards. Reached later it stays what it was,
a floor, and a floor below the arrival changes nothing — which is what keeps a
hotel behind an afternoon stop at its real arrival instead of dragging it back to
its check-in.
2026-09-13 16:22:07 +02:00
Maurice fcee6bd2c5 fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.

The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.

And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
2026-09-13 16:19:57 +02:00
Maurice c351a0471c fix(accommodations): a check-in decides where in the day the night sits
The stop went to the end of the day, always, on the reasoning that a night ends
the day. It usually does — but a check-in at eleven says the traveller is at the
desk before whatever they pinned to the afternoon, and putting the stop behind it
built a drive that reaches the hotel at seventeen past five after a sixteen
hundred it was booked around. The order looked like the traveller's and was not;
it was this code's default.

A booking with a check-in now takes the place its hour asks for: ahead of the
first stop pinned later, and still last when nothing is. Editing the check-in
re-seats it, in both directions, read off the chain rather than recomputed —
the index a fresh insert would get is not the index a row already holds.

Stops without an hour of their own are passed over in either direction. Their
place in the chain came from the traveller, not from a clock, and a night has no
business pushing past one.
2026-09-13 16:06:11 +02:00
Maurice 3f6d1e8ed7 fix(accommodations): re-export the module, not the provider inside it
The container refused to boot: Nest will not export a provider that belongs to an
imported module, and the service moved into the domain module in the commit
before. Re-exporting that module gives every importer the same service it always
got, and the wiring test now asserts the shape that actually boots rather than
the one that only typechecks.

Neither tsc nor the unit tests could see this — the module metadata is only read
when a container is built. The e2e harness does build one, and that is what
catches it.
2026-09-13 15:50:58 +02:00
Maurice 46aa0c6b0a fix(map): the base layer switch stays in its corner when a place opens
Opening a place sent it from the bottom left of the map to the top, which reads
as the control moving house rather than as room being made. Nothing was in its
way: the inspector is a card centred between the sidebars and at most 800 wide,
so the corner it was clearing is one the card never reaches.

Both renderers, since the switch is not supposed to move when an instance changes
its map.
2026-09-13 15:41:46 +02:00
Maurice 77a16d6f26 fix(places): deleting a place cancels the nights booked at it
A stay whose place was deleted kept its row with place_id NULL: still drawn in the
day header, still naming a hotel through its partner booking, and pointing
nowhere. Nothing could reach it any more and nothing cleaned it up.

It goes with the place now, through the accommodations domain rather than a
DELETE in the places path, because a stay takes its partner booking, that
booking's expense and the day stop it wrote with it — none of which is the places
domain's business to know. Both delete paths, single and bulk.

The service moves into an AccommodationsDomainModule, the assignments-domain
precedent: the service never touches PlacesService, only the MCP tool that writes
a place and a stay together does, so it can sit in a module PlacesModule is free
to import without closing a loop. AccommodationsModule keeps exporting it, so
every existing consumer is unchanged.
2026-09-13 15:36:02 +02:00
Maurice 7b50692372 fix(roadtrip): a check-in is a door opening, not an appointment
It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.

The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.

Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
2026-09-13 15:28:48 +02:00
Maurice 62d4cdcc18 fix(accommodations): catch the nights the upgrade itself could not reach
A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.

The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
2026-09-13 15:19:33 +02:00
Maurice a89ad06ba5 fix(roadtrip): a night is over in the morning, not two driving days later
With daily travel times set, a stay was spent only against the hours the window
is open. Twenty-four hours then took two of them to run down: a single night
ended on the day after next, and every stop behind it moved with it.

Night time is time. The hours between one window closing and the next opening now
count towards the stay, so a night that starts just after eight is over just after
eight the next morning and the day carries on from there. An eleven-hour visit
from eight is over at seven in the evening rather than saving its last hour for
tomorrow — the traveller is still there after the driving stops, they simply do
not drive on until the window opens again.
2026-09-13 14:42:54 +02:00
Maurice 7197ec1c96 fix(roadtrip): a stay that outlasts the day takes the road with it
Three things the day boundary was swallowing, all from the same stay.

The stop after it stayed on yesterday. It is reached a full day later but at a
later reading on the clock, and the only rule watching for a new day was "the
clock went backwards" — which that never does. The schedule's own count knows,
as long as it is read as a difference: a day whose first arrivals came out
negative is lifted whole, and reading the lifted offsets raw once moved an early
stop to tomorrow while leaving the late one behind. It now crosses over properly,
marked as having set off the day before.

The morning after started at the exact minute the stay ended, as if the road to
the first stop took no time. The carried minute is a departure; the drive from
there is added to it.

And that drive was invisible. It was on the map and in the day's total, but the
rail jumped straight to a stop at 08:11 after a stay that ended at 08:00. It gets
the same band any other drive gets, above the stop it arrives at — except where a
stop crossed over, which already draws that road under its own block.
2026-09-13 14:34:52 +02:00
Maurice 3d201e1ced fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock
A check-out is the LATEST the room has to be handed back, never the earliest
anybody may drive on, so it says nothing the chain can use. It shaped four things
it had no business shaping: the departure the day was built from, the length the
rail printed for a booked night, a day-selector and a clock field in the stop
dialog, and the branch that sent a night's STAY badge to the booking form because
its length was not editable here. All four go. The booking keeps its check-out;
it is a booking detail and lives under Days, where it is entered.

What replaces it is the thing that was always the honest answer: how long you
stand there. That is the traveller's own statement rather than a deadline, and a
stay running past midnight now carries the clock into the morning after — a stop
pinned before it ends keeps its hour and collects the late finding, as it would
from any leg it cannot make. A stay spanning whole days walks the clock forward
over them. An ordinary evening hands on nothing: the night between two days is
not a wait somebody is serving out.

The stay dialog goes to a full day rather than four hours, since it is now the
only place a night is given its hours, and offers eight and twelve as presets.
Every stop answers the same question the same way.

Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so
the first time each dialog was ever opened a pale block flashed under the planner
and never again once its chunk was cached.
2026-09-13 14:23:49 +02:00
Maurice f4809a67d3 Revert "fix(roadtrip): a night booked into the next morning holds that morning"
This reverts commit d4677bdea, and keeps the departure chip's icon.

The premise was wrong. A check-out is the LATEST moment the room has to be
handed back, not the earliest one you may leave: whoever wants to set off at
seven checks out before that or leaves the bags at the desk. Reading it as a
floor turned a perfectly good plan into a finding, and no amount of wording
around that would have made it true.

The chip keeps the door-out mark. That part says what it always said, only in a
symbol that is not a second clock face beside the arrival.
2026-09-13 14:01:18 +02:00
Maurice d4677bdeab fix(roadtrip): a night booked into the next morning holds that morning
Every day was scheduled from nothing, so a booking that runs into the next day
ended at midnight as far as the chain was concerned. Check in at 08:00 and out
at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00
that morning, with no sign that the room was still yours.

The day a check-out falls in now starts at the check-out rather than at nothing.
A floor, never a move: a stop with a clock of its own keeps it and collects the
late finding any leg it cannot make in time would give it, so a plan that says
07:00 still says 07:00 and merely admits it does not add up. A stop with no clock
starts at the check-out instead of having no time at all.

Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday
alone, because you sleep there, you do not sit there.

The departure chip takes the door-out mark instead of a clock face. Beside an
arrival time, a second clock read as a second arrival.
2026-09-13 13:23:32 +02:00
Maurice 6845db4cb8 fix(roadtrip): a check-in is a time somebody chose, so print it as one
The rail prints an hour in ink when a person picked it and in grey when the chain
worked it out. Every scheduler anchors on `time ?? checkInTime`, so a booked night
starts its day on the check-in exactly the way a pinned stop does — but two of the
three then asked only about `time` when labelling the result, and printed that
chosen hour grey.

The answer lived in three places and two of them disagreed with the anchor they
had just used, so it is one function now. Nothing but the rail reads the flag.

Only visible since a booked night became a stop of its own; before that no stop
carried a check-in.
2026-09-13 13:10:58 +02:00
Maurice 274dbea069 fix(planner): hide the booked night where the day list is actually drawn
The filter sat on the planner hook's assignments, but the day sidebar is handed
storedAssignments straight from the store, so it never saw it. The hotel was
listed as an ordinary place right under the overnight block naming it, on every
day someone booked a night for.

Filtered in getDayAssignments now, next to the service-stop filter that is there
for the neighbouring reason. That is the list itself, so the reorder and undo
paths keep reading the full day and nothing shifts underneath them. The phone
was never affected: its timeline reads the hook's copy.
2026-09-13 13:04:19 +02:00
Maurice d0d3f455a8 fix(planner): let the day list see which stop a booking wrote
The row shapers name every column they pass on, and accommodation_id was in
none of them. So the field the day list hides on never reached the client, and
the booked night was listed as an ordinary place next to the overnight block
that already named it. The stop was correct in the table the whole time; the
three read paths just dropped it on the way out.

The stamp on the freshly written stop had the same hole from the other side: the
INSERT went in without the booking id and an UPDATE added it afterwards, so the
row handed back in the answer, the one the client that booked the night puts
straight into its store, carried none. It goes in with the INSERT now.

The shared link gains the same filter. It has no road trip view to need the stop
and already shows the booking as its own chip on the day, so the row was only
ever the same hotel twice.
2026-09-13 12:59:19 +02:00
Maurice e3471e2dc8 fix(reservations): a night booked on the form belongs on the drive too
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.

It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.

Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
2026-09-13 12:38:21 +02:00
Maurice eff4605ef3 test(accommodations): cover the paths the day stop added, and the ratchet fell on
Six lines and four functions the mirror brought in were never entered by a test,
which is what took the domain under its coverage floor.

The keepStop branch of a delete, the plugin surface's own update, and the
transaction that has to roll a place and its stay back together. Plus every
sender a surface hands announceMirror: REST and the three plugin methods build
one each, and a fixture that swallowed the call let all four go unexercised --
the one link between a stay write and the sockets everyone else is watching.
2026-09-13 12:12:38 +02:00
Maurice edf0538260 fix(roadtrip): keep a booked night out of the day list, and off the traveller's stops
Three things the first pass got wrong.

A stop a booking puts on the day is for road trip mode, which needs the
hotel in the driving chain. The day planner already shows that booking as
its own overnight block, so the row there would be the same hotel twice.
It is hidden in Days whatever the service-stop switch says, and the switch
goes back to meaning what it says: the petrol stations the traveller added.

Cancelling a night from the road trip popup took the stop with it. That
control means "this is a pause now, not a night", and the stop sits
mid-drive where re-adding it would land it at the end of the day. The
booking now hands the stop over instead of deleting it.

Moving a booking whose stop belongs to the traveller put a second one on
the new day and left the first behind, which is the duplicate this change
exists to remove. Only a booking's own stop moves now. Stays booked before
this shipped get theirs from the migration, not from the next edit they
happen to receive.
2026-09-13 01:42:52 +02:00
Maurice 462a208fdf fix(planner): drop the hotel bookend leg when the hotel is the edge stop
The day's accommodation is drawn as a leg to the first and from the last
waypoint. Now that a booked night puts its hotel on the check-in day as a stop
of its own, that last waypoint IS the hotel on most days, and the leg drawn to
it is a round trip of zero kilometres: in the map line, in the sidebar's leg
list and in every exported directions link, which carried the hotel twice in a
row. The defect already existed for a night booked in the road trip; the change
above would have made it the normal case.

Both bookend rules now decline a leg whose edge stop sits at the hotel's own
coordinates, which covers the map, the sidebar, the phone route and both export
links from one place. Compared by coordinates rather than ids, because the
bookend comes off the stay row and the waypoint off the assignment.
2026-09-13 01:13:41 +02:00
Maurice 5e0245046d feat(planner): show the stop a booked night writes without a reload
The server announces the day stop over the socket like any other assignment,
but the socket deliberately skips the session that sent the request, so the one
person who could not see their new stop was the one who had just booked the
night. That looks exactly like the bug it fixes.

The stay writes hand their answer to one applier, which folds the stop into the
store through the same path the socket uses, so the IndexedDB write-through
happens either way and the dedupe by assignment id comes for free. Three
surfaces call it: the day-detail picker, its edit branch and the phone sheet.
The road-trip flow does not, because it writes its own assignment before the
stay and there is never a mirrored stop to apply.
2026-09-13 01:13:33 +02:00
Maurice f0e1da2d81 feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.

Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.

New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.

The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
2026-09-13 01:13:25 +02:00
Maurice 3ab5ddaacc Merge pull request #2349 from liketrek/feat/dawarich-integration
feat: Dawarich integration
2026-09-13 00:05:05 +02:00
Maurice 8a28d4dd4a test(server): stop racing supertest's own listener in the scope e2e
Three cases fired their reads through `Promise.all`. The Nest app under
test is never told to listen, so supertest binds an ephemeral port on the
first request; four of those at once race four `listen(0)` calls on the
same server and the losers come back as ECONNRESET. It held here and
failed on a loaded runner. The reads run one after the other now, which
is all they ever needed.
2026-09-12 23:55:22 +02:00
Maurice 105bba2b95 docs(wiki): describe the Dawarich integration
What it reads, what it never writes, what happens to the recorded
positions, and how the connection, the suggestions, the Atlas marks and
the wish list behave from the reader's side.
2026-09-12 23:37:13 +02:00
Maurice 5b4a7e82ff feat(client): show Dawarich stays, routes and marks where they belong
The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
2026-09-12 23:37:05 +02:00
Maurice 5678eed55c fix(client): use TREK's own controls for tooltips and journey dates
Three small things noticed while working nearby, none of them Dawarich:

The map's layer switcher showed the browser's own tooltip while every
control beside it showed TREK's, so hovering along the row produced two
different boxes in two different styles.

The journey entry editor mixed TREK's date picker with a native time
input, and the date, which is the longer of the two, had the narrower
column: "12. Sept. 2026" wrapped while the clock sat half empty. The row
is split in the date's favour now and the picker is disabled together
with the rest of the form instead of staying live behind it.

On the phone's journey, the dock's "+" now turns into an upload button
while the Gallery tab is open, which is what that tab's one big action
actually is.
2026-09-12 23:36:49 +02:00
Maurice 218d8baf0a feat(server): read visits and recorded routes from Dawarich
A new `integrations/dawarich` domain behind its own addon. Each reader
connects their own instance; the API key is encrypted at rest and never
returned. Nothing is written back to Dawarich, and no raw position is
kept in TREK: the recorded route is fetched per request for the window
being looked at and thrown away again.

What it produces are suggestions, never facts. A recorded stay becomes a
proposed place or journal entry, a country or city becomes a proposed
Atlas mark, and a stay near a wish becomes a proposed tick-off; each one
waits for the reader to confirm it. Identity is (user, Dawarich visit),
so a second sync neither duplicates nor overwrites what was accepted,
and deleting the result puts the suggestion back on the list.

The sync runs as a job on the central scheduler, one user at a time,
guarded against overlapping runs.
2026-09-12 23:36:49 +02:00
Maurice dad1316172 feat(api): let public API keys be scoped to single areas
A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.

Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
2026-09-12 23:36:09 +02:00
Maurice 906eef4349 feat(shared): add Dawarich contracts
Zod schemas for the Dawarich integration: connection settings, the
suggestion list and its accept payloads, the recorded-track window and
the Atlas country/city proposals. Places gain an optional `source` so a
place that came out of a recording can say so.
2026-09-12 23:36:08 +02:00
Maurice b010305c0d Merge pull request #2348 from liketrek/fix/collab-links-polish
feat(collab): make the links panel look like the rest of the collab tabs
2026-09-12 19:44:20 +02:00
Maurice 3529b72393 feat(collab): give links the header and empty state its siblings have
The tab was built to its own pattern: a bold title where the other four
carry a small uppercase one, a counter instead of the add button, the add
form as a light block sitting on top of the list, and a grey line of text
where every other empty panel shows the mascot. Side by side with Notes it
read as a different app.

It now uses the poll panel's shell: the same header, the same pill button
top right, the same modal for the form, and the mascot holding a chain.

The load effect depended on the toast and translation objects, both of
which are new on every render, so the list refetched constantly and a link
that had just been added was dropped again by the response in flight. It
depends on the trip alone now, and reads the other two through refs.
2026-09-12 19:37:07 +02:00
Maurice dd5b45ffda feat(collab): a chain for the mascot to hold on an empty links panel 2026-09-12 19:37:07 +02:00
Wenyin Wei ef259e7d7d fix(shared): keep the header's decoration from widening the page
The two decorative circles in the header are positioned outside it on purpose
(-40 to -60 on each axis) and nothing in the ancestor chain clips them, so the
first one widened the document by its own overhang: a 390px phone reported a
450px scrollWidth and the whole page could be dragged sideways (#2345).

overflow: hidden on the header is what the decoration always meant.
2026-09-13 01:13:34 +08:00
Wenyin Wei 12eabbb685 fix(shared): cluster the public share page's markers
The share page drew every stop as its own pin while the planner drew the same
trip as a handful of clusters, so a shared link opened on three dozen
overlapping markers. On a 300px-tall map that covers a whole city, the pins
underneath can be neither read nor tapped (#2343).

The option block and the icon factory move to components/Map/markerCluster.ts,
which both maps import, so the two cannot drift apart again. The planner's
options are unchanged.

leaflet.markercluster's two stylesheets were imported by MapView.tsx alone.
The share page never mounts MapView, so it imports them itself: without them
the bubbles keep the library's default styling and spiderfy's legs are
invisible.
2026-09-13 01:11:14 +08:00
Maurice 11b69ae815 test(places): give the hand-built places tables the Amap id column 2026-09-12 18:07:39 +02:00
Maurice 79095823e5 chore(maps): drop the attribution import the tile helper replaced 2026-09-12 18:03:11 +02:00
Maurice f2bd116044 docs(maps): an Amap link needs a coordinate
A bare amap.com/place page carries an id and nothing else, and turning that
id into a location would need a keyed detail lookup the URL resolver has no
user for. It answers 400, which AMAP-084 pins. The wiki offered the link as
paste-able and the parseAmapUrl comment still promised the lookup, so both
now say what the code does.

The provider header also claimed photoRefs/photoBytes were deliberately
left out, naming two methods the places-provider interface no longer has.
The reason is unchanged: an Amap picture comes without a licence statement
we could show, so an Amap place gets its picture from Wikimedia.
2026-09-12 18:03:11 +02:00
Maurice ba535e5aab fix(maps): ask Amap only where Amap can answer
With Amap in the keyed slot every reverse lookup went to Amap first,
worldwide. Outside its box that buys a round trip and an empty answer
before Nominatim is asked anyway, which is latency on every right click in
Lisbon. The client side of the same question already filters by the box
(placeNavigation.ts); isOutsideChina is the shared helper both use.

The two fixtures in the file that were cast to any now carry a type: the
key row and the fetch spy whose calls the reverse case reads back.
2026-09-12 18:03:11 +02:00
Maurice aa0d62f063 fix(admin): read key presence from the app config, not the form
The place search provider block now renders on a managed install too, and
there the key fields are empty by design: the operator owns the credentials
and user-profile never sends them. An operator key set through the
environment is invisible in the fields either way. The warning behind the
provider select read those fields, so it told every managed admin that
search "falls back to OpenStreetMap" while their key was answering.

app-config already carries the answer as has_maps_key, and it resolves the
same chain the search itself uses, environment included. has_amap_key sits
beside it, so both come into the store at boot and both follow a save.
2026-09-12 18:03:11 +02:00
Maurice 3e8ab22836 docs(maps): Amap beside the TREK index
The index and OpenStreetMap answer every search; the provider choice
only decides who fills the keyed slot next to them. Rows carry a source
mark, a place keeps its Amap id, and the journey map shares the shifted
projection.
2026-09-12 18:03:11 +02:00
Maurice 2332d45d01 i18n(maps): Amap strings for Catalan, Swedish and Vietnamese 2026-09-12 18:03:11 +02:00
Maurice 2fd5b6b00a fix(admin): keep the place search provider reachable on a managed install
The API keys card was hidden wholesale on a managed install, which took
the provider choice with it although the server leaves places_provider
to the admin there. The card stays; the keys, their Save button and the
Google switches fold away, on the phone shell now too. The mobile
warning uses the pending token instead of an undefined one.

The desktop tests find a key input through its labelled toggle rather
than by position, which is where the accessible name sits.
2026-09-12 18:03:11 +02:00
Maurice f120266f9e fix(pwa): cache Amap tiles like the other raster presets
Without a Workbox rule for is.autonavi.com the offline pre-download
fetched the tiles and stored none of them, the hole #2180 closed for the
OpenStreetMap DE and Stadia presets.
2026-09-12 18:03:10 +02:00
Maurice fdb993de62 fix(maps): project the journey map like the planner on a GCJ-02 basemap
The journey map read the same tile setting as the planner but built its
Leaflet map without the shifted CRS, so an Amap basemap put every entry a
few hundred metres off. A change of datum rebuilds the map, since Leaflet
cannot swap a CRS after construction; every other template change still
retiles in place.
2026-09-12 18:03:10 +02:00
Maurice 84c277993c feat(maps): show where an Amap hit came from, and keep its id
An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.

amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
2026-09-12 18:03:10 +02:00
Maurice 43a6c30325 feat(places): remember the Amap id a place was found by
The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
2026-09-12 18:03:10 +02:00
Maurice 1944bdf0a5 feat(maps): let Amap take the keyed provider slot beside the TREK index
The rebase kept maps.service.ts as dev has it: the TREK index and
OpenStreetMap answer every search first, and a keyed provider only fills
what they leave empty. Amap now sits in that slot next to Google instead
of behind an extracted Google provider, which is dropped.

places_provider (auto | google | amap | openstreetmap) decides which keyed
provider holds the slot; auto keeps whatever the install already used.
An amap: id opens against the Amap key regardless of the setting, takes
the plain details lookup on expand, never reaches Google's photo call,
and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding
asks Amap at the instance key before Nominatim.

The migration carries users.amap_api_key, which the rebase had left at a
stale array position, and places.amap_poi_id for the next commit.
2026-09-12 18:03:10 +02:00
A1phaN 57eac32f9f feat(map): amap as map api provider and display template 2026-09-12 18:02:57 +02:00
Maurice a066c94ec5 fix(costs): tell the phone when the settlement did not load
The mobile Costs tab swallowed a failed settlement request, and the final
budget card then filled every traveler's row from the empty fallback: "0.00"
for everyone, which reads as "the trip cost nobody anything". The desktop
panel already keeps a settlementError flag and prints its notice instead of a
number; the mobile tab now does the same in the final budget card.
2026-09-12 17:33:15 +02:00
Maurice 9307025ed0 fix(costs): derive the final budget breakdown on the server, in the same cents
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.

The settlement response now carries the rows with the figure: `sources` on each
finalBudgets entry lists what the person fronted per expense, the recorded
transfers on their side and the open flows, in display cents. They are filled
by the same two loops that fill the totals, so an expense with no split members
stays out of them as it stays out of the ledger, and each list is spread over its
figure's already allocated cents with the same largest-remainder split, so the
sum is exact in every display currency. The shells print those cents and only
resolve names; the rows carry the sign of the line they sit under.
2026-09-12 17:33:15 +02:00
Maurice 66761d57bb i18n(costs): name the reimbursement line for both sides
"Reimbursements received" was written from the receiver's chair: the traveler who sent a payment saw it as a plus under a label claiming they had received something. The line is net of both directions, so its label now says so in every locale.
2026-09-12 17:33:15 +02:00
RemEx e0c9a8d1b6 feat(costs): show what the trip finally costs each participant
The settle-up view says who owes whom, but nothing on the Costs screen
answers "what did the trip cost me": the balances move every time a
payment is recorded, and the tiles only describe the signed-in user.

The settlement response now carries finalBudgets, one row per participant
in the ledger: expenses (what they fronted on split expenses), reimbursed
(recorded transfers, received minus sent), pending (their current
balance) and final = expenses - reimbursed - pending.

- Computed in calculateSettlement from the same integer trip cents as the
  balances, filled by the same loops, so the breakdown can never
  contradict the balance printed next to it. Each component is converted
  to the display currency as its own set and the final is subtracted in
  display cents, so the three lines add up to the total in any currency.
- A "Final budget" card on desktop, the narrow desktop layout and the
  mobile Costs tab: one amount per traveler, and a tap opens the three
  lines plus the expenses, transfers and open flows behind them. The
  filtering lives in CostsPanel.helpers so both shells share it.
- Contract in shared (budgetParticipantFinalSchema); the MCP
  get_settlement_summary description mentions the new field it returns.
- Four new costs.* keys, translated in every locale.
2026-09-12 17:33:15 +02:00
Maurice a635e31bd4 test(db): pin the roadtrip preferences replay to its own schema version 2026-09-12 17:33:04 +02:00
Maurice bc6d804af6 docs(budget): say what settled_at does when it is left out 2026-09-12 17:33:04 +02:00
Maurice 259a8c6b4d test(budget): settled_at through the HTTP pipe
The e2e settlement round trip now sends settled_at on create and update
and reads it back from the column, including a PUT that clears it to
NULL. The service test covers the clear branch for null and the empty
string the date picker sends.
2026-09-12 17:33:04 +02:00
Maurice 953720c874 fix(client): clamp a flipped date picker to the viewport
Anchoring the flipped popup to the trigger's top edge dropped the old
vertical clamp: with a short viewport the popup's top edge left the screen
and the month header became unreachable. The popup now flips up only when
there is room above; otherwise it stays below and is clamped so its top
edge never leaves the viewport.
2026-09-12 17:33:04 +02:00
Maurice aa848cc6b1 fix(budget): keep a payment's currency when it is edited on a phone
The mobile payment sheet always sent the display currency, while it seeded
the amount raw in the currency the payment was recorded in. Correcting only
the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX
rate for it. The sheet now carries its own currency, seeded from the
payment (or the display currency for a new one) with the same picker the
expense sheet has, and the payment row shows the recorded amount next to
the converted one when they differ, like the desktop row does.

The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
2026-09-12 17:33:04 +02:00
RemEx 3dd6674d3f feat(budget): let a recorded payment be edited from mobile Costs too
Editing/undoing a settle-up payment was desktop-only — the mobile ledger
row rendered it read-only, with a comment explaining the gap was
deliberate for now. Give it the same edit/undo affordance the expense row
already has: edit reopens AddPaymentSheet pre-filled (now doubling as an
edit form, same as desktop's SettlementModal), undo deletes it outright
with no confirmation, mirroring CostsPanel.tsx's undoSettlement.
2026-09-12 17:33:04 +02:00
RemEx bef11c5222 fix(client): keep a flipped date-picker popup anchored to its trigger
CustomDatePicker flipped above a trigger by subtracting a fixed worst-case
height (360) from its top, regardless of the popup's real rendered height.
When that guess overshot — a short calendar view, or a form with little
room, like the settle-up modal — the gap left the popup floating away from
its trigger and over unrelated fields instead of sitting right above it
(#2334, found while testing the settle-up date field).

Anchor the flipped case from the trigger's own top edge via `bottom`
instead, the same technique useAnchoredPosition already uses for other
popovers, so the popup is always flush against its trigger no matter its
actual height.
2026-09-12 17:33:04 +02:00
RemEx 3a8ae6334c test(budget): match applySettlementUpdate's new settled_at SQL args
The unit test mocking db.run for applySettlementUpdate asserted the exact
positional argument list, which the settled_at column added two more
positions to (its CASE-WHEN flag and value, mirroring currency/exchange_rate).
2026-09-12 17:33:04 +02:00
RemEx bd46089450 feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
2026-09-12 17:33:03 +02:00
Maurice 76e182f44e sort route avoidance classes explicitly 2026-09-12 17:26:38 +02:00
Maurice 51626e8184 make roadtrip availability checks explicit 2026-09-12 16:46:22 +02:00
Maurice bc04072dc9 keep overnight checkout planning after a later arrival 2026-09-12 16:22:11 +02:00
Maurice bc15036cfb cover roadtrip MCP authorization and search filters 2026-09-12 16:07:36 +02:00
Maurice 0c78462567 show only planned places on the roadtrip map 2026-09-12 16:05:02 +02:00
Maurice 4f1d0aa210 adjust map clustering and refresh system sqlite 2026-09-12 16:02:01 +02:00
Maurice 465f67ae05 Merge pull request #2334 from liketrek/codex/roadtrip-day-boundaries
Add configurable Roadtrip day endings and station clusters
2026-09-12 15:52:25 +02:00
Maurice 8b99188fc8 fix roadtrip regression test fixtures 2026-09-12 15:47:57 +02:00
Maurice ac57321840 merge dev and reconcile roadtrip planning 2026-09-12 15:45:34 +02:00
Maurice b10b02c685 finish overnight stops and cross-day range warnings 2026-09-12 15:37:09 +02:00
Konstantinos Thermos 359551653f Merge pull request #2319 from liketrek/feat/trip-route-overview
feat(maps): draw every travel day's route at once, with the trip total
2026-09-12 14:49:44 +03:00
Maurice f56e7a161e add open charging data and polish roadtrip warnings 2026-09-12 13:25:15 +02:00
Maurice 228e6a13a9 fix(admin): say what the local model actually gets 2026-09-12 13:07:08 +02:00
RemEx b58771fd4c fix(admin): recommend the local model that also reads images
The one model the addon offers to pull was Qwen3 8B, which is text-only, so a
scanned page or a photographed document reached it as no text at all and came
back empty. Qwen3.5 4B is multimodal at a third of the size: one download
covers every kind of document instead of leaving the operator to work out that
the recommended model cannot read half of them.

The list stays one entry long. A self-hosted host needs a model, not a menu,
and any other id can still be typed in or picked from what Ollama already has.
2026-09-12 13:07:08 +02:00
Maurice b7ed11ef4a Merge pull request #2335 from harl00/patch-1
Clarify settings path for creating machine client
2026-09-12 13:03:51 +02:00
Maurice b90d258b6c docs(mcp): say whose settings the OAuth client lives in 2026-09-12 12:55:34 +02:00
Luffy e8431d6426 fix: close file picker on outside click and modal dismiss 2026-09-12 12:55:01 +02:00
Maurice 41b18f970c feat(vacay): add manual school holiday calendars 2026-09-12 12:51:27 +02:00
Maurice ff5fa55fad add Google Maps route import and plugin corridor search 2026-09-12 12:24:33 +02:00
Maurice af45b0ca74 add roadtrip warning areas and tidy driving settings 2026-09-12 11:23:09 +02:00
amo c0e3fde6f7 Clarify settings path for creating machine client
Updated instructions to specify 'user' in settings path. Without it, minor confusion to be still operating in main 'Admin' settings where you enabled MCP integration
2026-09-12 13:08:18 +10:00
Maurice ec281e5974 fix(roadtrip): shorten day-ending settings 2026-09-12 00:34:12 +02:00
Maurice b773faff70 feat(roadtrip): share trip preferences and improve service stops 2026-09-12 00:24:45 +02:00
Maurice dd6ad31ab9 fix(roadtrip): keep day-ending labels draggable after icon changes 2026-09-11 23:30:38 +02:00
Maurice fccc55732b feat(roadtrip): plan and edit trips through MCP without a browser 2026-09-11 23:25:06 +02:00
Maurice 491cfccc56 feat(roadtrip): add daily scheduling and configurable day endings 2026-09-11 22:28:03 +02:00
Konstantinos Thermos 3c735c80a4 Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview
# Conflicts:
#	client/src/components/Map/MapView.tsx
#	client/src/components/Map/MapViewGL.tsx
#	client/src/pages/TripPlannerPage.tsx
#	client/src/pages/tripPlanner/useTripPlanner.ts
2026-09-11 16:22:21 +03:00
Maurice 4f8605b529 merge dependency maintenance #2300 into dev 2026-09-11 15:21:10 +02:00
Maurice 50839f8cc1 copy the map sanitizer patch before Docker installs dependencies 2026-09-11 15:02:06 +02:00
Maurice 8ee2b6ae4c merge dependency maintenance #2322 into dev 2026-09-11 15:01:15 +02:00
Maurice 3e37a708c7 merge dependency maintenance #2324 into dev 2026-09-11 15:01:01 +02:00
Maurice d8f2dc755d merge dependency maintenance #2323 into dev 2026-09-11 15:00:47 +02:00
Maurice 134fcbb424 merge dependency maintenance #2314 into dev 2026-09-11 15:00:35 +02:00
Maurice 2c06c9904b feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.

What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.

What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.

A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.

An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
2026-09-11 14:58:34 +02:00
Maurice 67dd43bc8a fix(maps): backport attribution sanitizer fix without dropping WebGL1 2026-09-11 14:55:37 +02:00
Maurice f5ef14a05f build(deps-dev): update vitest and coverage together 2026-09-11 14:35:51 +02:00
Maurice 7eba81dfd8 build(deps-dev): update sharp with its musl bindings 2026-09-11 14:35:50 +02:00
Maurice edb9a55099 build(deps-dev): refresh baseline browser data 2026-09-11 14:35:50 +02:00
Maurice 74a0825f5a build(deps): update hono to 4.13.7 2026-09-11 14:34:33 +02:00
Maurice a8e4c45576 feat(share): show a stop and a booking in full on a shared link, and no more (#2320)
A public link showed a stop as its name and one line under it, the address
or the description, whichever came first. The owner had written both, and
a note on the place and another on the day, and had set how long they meant
to stay and how to reach it. All of that shows now, laid out the way the
planner lays it out, and every link leaves the page in a new tab with no
opener. A booking shows the note the owner attached and the page they
linked, labelled by where it goes.

What travels to the page is chosen column by column now, where it used to
be the whole row. The whole row of a booking carried its confirmation
number, its import trail and its raw metadata, and the metadata is where an
imported ticket keeps its seat and its record locator; the whole row of a
place carried the owner's booking notes on it and the Google identifiers.
None of that belongs to somebody holding a link. Metadata keeps the parts
that describe the journey, by an allow-list, so a field the import starts
writing tomorrow does not become public by appearing. Links are accepted
only as http or https, on the server and again on the page.
2026-09-11 11:01:25 +02:00
Maurice 87c73e50fb feat(studio): give a book its first and its last page (#2317)
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.

The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
2026-09-11 11:01:15 +02:00
Maurice 1128bcd1c5 feat(journey): let the photo picker's calendar jump years (#2318)
The date range on the photo picker had a calendar that moved one month per
click and no other way to move. A trip three years back was thirty-six
clicks away. The header is a button now, the way the app's other calendar
works: click the month to see the year's months, click the year to see a
page of twelve years, and the arrows step whichever level is showing. A
pick steps back down, and reopening starts on the days again. The same
component serves the entry editor, which keeps its trip-day dots.
2026-09-11 11:01:04 +02:00
Maurice 7946c2f749 fix(maps): measure a booking line along the road before hiding it (#2275)
A short transport hop is hidden once its two ends sit closer than a few
dozen pixels, so a booking that would draw as a dot does not clutter the
map. That check compared the endpoints, which was the whole line while
every hop was straight. A car booking is routed along the road now, and a
road that leaves a town, rounds a lake and comes back to the next town along
can run across half the screen while its ends project sixty pixels apart.
The whole drive vanished until the map was zoomed far enough in for the
ends to separate, which is the gap in the route the report shows.

The decision now walks the line that will actually be drawn, road route or
arcs, and keeps the hop as soon as that reaches the floor. A straight
two-point hop measures exactly what it measured before, so every existing
threshold holds for the case it was written for. One helper serves both
renderers, which also folds the two hand-copied floor tables into it.
2026-09-11 11:00:57 +02:00
Maurice 5ef60f5641 fix(todo): describe the MCP priority the way the app reads it (#2297)
The create_todo and update_todo tools said 1 was low and 3 was high. The
app, its sort and the wiki all say the opposite: 1 is P1, the red one at the
top of the list. An assistant that followed the description filed every
urgent task as the least urgent. The words now match the badges, and the
update tool, which said nothing at all, says the same and that null clears
it. No stored value changes.
2026-09-11 11:00:45 +02:00
Maurice 9ce8b7ea9e test(roadtrip): cover the fold, the seam and the live via, and fix what that found
Cases appended to four existing suites for the lines this branch left unrun: the
fold state and the map places it hides, the owner-based via anchor, the day seam
and the proportional geometry slicing, and the live roadtripVia:changed events.
With the four new suites in the commits before this one, coverage of the lines
this branch adds goes from 69 % to 96 %, measured the way the gate measures it —
the routed lines against the diff, not a file average.

Every added case was mutation-checked: the rule it claims to pin was broken in
the source and the case had to fail. Two of them found real defects rather than
missing coverage, and both are fixed here.

A via dropped on the incoming night drive went to the wrong leg. The guard for
it asked the insert index whether the point lay before the card's first stop,
and that index cannot answer: insertIndexForAlong clamps to at least 1 for any
card with two stops or more, and the rail publishes no other kind. So the branch
read correctly and never ran, and a via on the night stretch was filed after the
first drawn stop — the route ran forward, doubled back to the point and carried
on, which is exactly what the comment above it said must not happen. Asked of
the distance instead.

The two one-field writes the rail offers, the stop kind and the fill level, went
straight to the API while their neighbour checked place_edit first. The server
refused them, so nothing was ever saved, but a reader got an error toast for
touching a control that should not have acted.
2026-09-10 21:40:51 +02:00
Maurice a9907e2f10 feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
2026-09-10 21:40:51 +02:00
Maurice a00cc889ed feat(roadtrip): show every change to a road trip live to everyone on it
Two people shaping one drive were each editing their own copy: a via dragged on
one screen reached the other only on the next reload, and the route it changed
looked settled until then. Every write in the domain now announces itself over
the websocket the rest of the trip already uses — the five REST routes and the
matching MCP tools alike, so an assistant moving a stop is as visible as a
person doing it.

Two new trip-scoped events carry it: roadtripVia:changed with the day's whole
via list, and roadtripTrack:changed. The whole list rather than a delta, because
a via is identified by its position in a chain and a delta protocol would need
an ordering guarantee to be worth anything.

The originating socket is excluded through the X-Socket-Id header the other
domains already forward, so nobody watches their own change arrive twice.
2026-09-10 21:40:51 +02:00
Maurice 3f0eb210d8 feat(roadtrip): move a stop reached after midnight onto the day it is reached
A day that runs past midnight used to keep every one of its stops on its own
card, so day one ended at 03:12 with three places nobody reaches on day one and
day two began with a drive that had already happened. The rail now lays the
stops out by the date they are actually reached: when the clock goes backwards
between two arrivals the ones after it move to the following day, into a block
that says where they came from, and the night drive and its kilometres go with
them. It is an arrangement, not a write — the day planner beside it is
untouched, nothing reaches the database, and a shorter stay the evening before
puts everything back on its own.

The map follows the same arrangement, because a boundary drawn in the rail and
not on the map is two answers to one question. Day one now ends where its last
reachable stop is and the line, the legs and the totals are cut to match.

Two switches in the driving settings, off by default. "Connect the days" routes
the gap between one day's last stop and the next day's first, which is real
driving the rail has never drawn, and counts it. "A colour per day" then puts
the days back into a line that has become continuous, and tints each day header
to match.

Collapsing a day from its header takes its route and its places off the map as
well as out of the rail, and the categories in the corridor panel fold the same
way. The refuel band is one full-width red band with the pump as its button,
the results are chips, and the driving dialog is two columns of panels with the
figures on one right edge.

Also fixes a via that would not adjust the route once dragged. Five separate
causes, all measured rather than guessed: leg threading used the card index
instead of the owning day's, the anchor lookup returned chain indices, it
filtered by the card rather than by the anchor's owner, seams were routed
without their vias, and a seam once routed was never re-fetched.
2026-09-10 21:40:51 +02:00
Maurice bd6e6312e1 fix(roadtrip): keep a typed range when the parts round away to nothing
A consumption typed as 500 rather than 5.0 makes the tank arithmetic round to a
range of zero. Zero is a number, so the `??` in effectiveRangeKm took it as the
answer and discarded the range the traveller had typed by hand next to it, and
useVehicleRange then folded that zero to null. What reached the rail was "no
range at all": every fuel and charge warning went quiet, with nothing on screen
naming the field that did it.

A range that rounds below one kilometre is a typo rather than a car, so it is
reported as no answer and the typed figure gets its turn.
2026-09-10 21:40:51 +02:00
Maurice 96ab748f34 feat(roadtrip): let one stop say how full it fills
The fill level was one figure for the whole trip, and a trip does not have one.
A motorway rapid charger is worth about 80 % because the last fifth costs as
long again; the charger at the hotel is worth all of it because the car stands
there all night. Between those two the difference is a whole leg, and a single
setting has to be wrong about one of them.

So the badge on a refuelling stop is now the way in: it shows what that stop
will actually fill to, its own figure or the traveller's, and opens a panel of
the six answers people actually give. Under them it says what the choice buys in
kilometres, because 80 % is an abstraction and "about 290 km from here" is the
thing being decided. Picking the figure a stop already has hands it back to the
setting, the same gesture the kind picker uses to un-pick a kind.

The value lives in places.fill_percent, beside stop_type, because it is a
property of the stop rather than of the person. Null means "follow the setting",
which is every stop that predates the column and every stop nobody has an
opinion about, so nothing changes for a trip that never touches it.

Two pieces of shared machinery came out of it rather than being copied. The
popover chrome — measure before showing, flip when the window is short, close on
an outside click, Escape or a scroll — is one component now, used by the kind
picker and this one. So is the assembly of the range from seven settings, which
the routes hook and the rail both need and which would have drifted the first
time a field was added.
2026-09-10 21:40:51 +02:00
Maurice aaf6559d5d feat(roadtrip): state a range as the car it comes from
A range could only be typed as one number, and that is the number fewest
travellers actually know. A tank size is on the filler flap, a consumption is in
the manual, a battery capacity and a kWh per hundred are on the data sheet. All
of them divide into the same figure, so the dialog now takes either and does the
division itself. What the parts say wins over what was typed, because it is the
more specific answer, and the range row goes read-only when they do so there is
never a second answer to the same question.

Of ABRP's list only battery wear is here. Plug type, reference speed, relative
speed and drive style shape a consumption PREDICTION; this addon does not
predict one, it divides a capacity by a consumption the traveller states, so
those fields would add typing without moving a kilometre. Wear is different in
kind: it shrinks the capacity itself, and on an older car by enough to decide
whether the last leg of a day arrives.

Storage stays metric throughout, the same rule the range itself follows. An
imperial traveller types gallons, miles per gallon and kWh per 100 miles and
gets them back unchanged; mpg is the inverse of litres per hundred rather than a
scale factor, which a straight multiplication would have stored as a car four
times as thirsty.

The gauge under the picker is the argument that these two ways of stating a
range are the same thing, so it answers while the figures are still being typed
rather than after the field is left. It draws the tank the day it left the
factory: the filled part is what a stop actually puts back in, the hatched tail
is what age has taken off, and both are things the bare number cannot say.

Also here, from the same round: a fill-up that stops short of full. Nobody
charges to 100 % on the road, and counting every stop as a full tank overstated
everything after it by that last fifth. Stops that refuel now carry a badge
saying how far they fill.
2026-09-10 21:40:51 +02:00
Maurice 5b147af0b6 feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band
Four things that only showed up once it was on screen.

The range budget treated a petrol station and a charger as the same thing, so an electric
car pausing for coffee at a filling station had its battery refilled on paper: the
warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can
now be named, and then only what actually refills it counts — and the search asks for that
kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the
old behaviour, which is what everybody who never opens the dialog is on.

The band was its own shape in a rail made of drive bands. It is one now: the same rail,
the same height, the same type size, a warning tint instead of a ring, and the figures
where a drive band keeps them. The buttons are icons of the same 18px square the shuffle
mark uses, so the band stays one line beside its neighbours.

The old range warning stood down where the band shows. Both at once said the same thing
twice in the wrong order — the answer above the problem — because the warning marks the
stop somebody finds out at while the band sits on the leg the fuel ends on.

Two measured corrections to the search itself. The server turns a box into a circle of
half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35
and came back flagged as narrowed EVERY time, which read on screen as "we could not
check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before
the dry point rather than on it, because only road already driven is any use — centred on
the point, half the circle was spent on road nobody can reach.

An answer that finds nothing now leaves the button rather than only a close. The place
search is a shared public service that does time out, and "it did not answer" with no way
to ask again reads as broken rather than as busy.
2026-09-10 21:40:51 +02:00
Maurice 95d1752a3d feat(roadtrip): suggest where to fill up, not just warn that you cannot
The range budget has always been able to say "you have driven 245 km on this tank" and
never "and here is where to do something about it". The review put it plainly: the value
is in the suggestion, not in the warning.

The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The
fuel runs out earlier, often most of a leg earlier, and a filling station offered at the
warning is one the car cannot reach. So the dry point is computed where it actually is,
a search runs around it on request, and what comes back is filtered to what can be
reached.

Three filters, each one there because its absence gives a wrong answer rather than an
untidy one. Only what lies before the tank empties, because a pump past it is on the far
side of an empty tank. The detour counts double, because 8 km off the road is 16 km of
driving. And a reserve is kept back, because the range figure is a round number from a
manual, not a measurement.

Sorted by the detour rather than by how far it gets. Measured on a real day between
Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one
kilometre more of range, while one 0.9 km off sat almost as late.

Deduplicated by name and half a kilometre, which also came out of that measurement. OSM
maps a charging site as one node per socket: the first three offers were the same
"autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m
apart with 450 m between the pairs, while genuinely different branches of one chain were
18 to 36 km apart — so anything from 500 m upwards separates the two cases.

An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after
a request that failed or was cut short states something that was never checked, and the
server flags both cases itself. Offline it does not offer at all: the place write queues
offline but the day assignment does not, so accepting one would leave an orphan.

Nothing is searched until it is asked for, and one press is one request against a small
circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to
the same popup a corridor hit goes through, so the stop kind, the dwell time, the
position, the via re-anchoring and the re-route are the paths that already work.

The dry point counts DRIVING metres only. The day's drawn line concatenates every run,
ferries and walks included, while the budget skips those, so walking that line to the
same figure would overshoot by the length of any crossing and put the marker out at sea.
2026-09-10 21:40:51 +02:00
Maurice c9e0fe52d5 wip(roadtrip): the point where the tank runs dry, as a place
deriveDriveWarnings knew the distance and threw the position away. The warning is filed
against the stop the car ARRIVES at, which can be most of a leg past the point the fuel
actually ran out — fine for a warning, useless for a suggestion, because a filling
station offered at the warning is one the car cannot reach.

emptyAt is a fourth field on the return object rather than a field on each warning, for
two reasons. Five tests assert warnings with toEqual and an extra defined property fails
them all. And a dry point is once per TANK while a warning is once per stop: a long
stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning
would stack three identical offers for one fill-up down a single day.

drivenMeters counts driving legs only, which is the whole reason it is counted here
rather than derived later. The day's drawn line concatenates every run, ferries and walks
included, while the budget skips those — so walking that line to the same figure
overshoots by the length of any crossing the day contains and puts the marker out at sea.
pointAtMeters walks the driving-only line instead.
2026-09-10 21:40:51 +02:00
Konstantinos Thermos bf1900c3a0 feat(maps): draw every travel day's route at once, with the trip total
Extract the day route builder out of useRouteCalculation into a pure module and
aggregate it over the whole trip: one map with every day's route, each day in its
own colour, the mode per stage and the trip's total distance. Straight lines first,
roads as they answer; a refused leg keeps its line and adds no distance.

Give the floating map controls one shared drop shadow while adding the toggle, so
the new pill and the base-layer switcher stand off the tiles the way the locate
button always has. Document the control in wiki/Map-Features.md.

Addresses #1736
2026-09-10 15:57:48 +03:00
Maurice 59b7c35e87 fix(roadtrip): re-pin a via to the leg it was dragged onto
Dragging a via somewhere else on the map changed where the point IS and never where it
sits in the chain. The anchor that decides which two stops it is threaded between was
worked out once, when the point was dropped, and the drag sent only coordinates —
`roadtripViaUpdateRequestSchema` carried a lat and a lng and nothing else.

So a via pulled past the stop it used to precede kept claiming the earlier leg. The
router then drove out to it and turned back before carrying on, which on screen is not a
subtly wrong route: it is a spur that reads as the drag having done nothing at all, with
the original road still sitting there unchanged.

The anchor is not a property of the point but of where the point sits along the drive, so
it is recomputed on a drag the same way it is on a drop. The projection that did that was
inline in addRoadtripVia; it moves to a shared `anchorFor` and both callers use it.

A drag differs from a drop in two ways, both deliberate. It measures against its OWN day:
letting the nearest day win, which is right for a click on open map, would hand the via to
a neighbouring day whose road happens to pass closer and make it disappear from the day it
was dragged in. And it has no 2 km guard: pulling a via well off the current road is
exactly what the gesture is for, and the anchor only says which leg gets bent.

`after_order_index` is optional on the way in, so a drag inside one leg and an older
client still send just the coordinates, and the stored pin is then left alone rather
than cleared.
2026-09-09 19:03:00 +02:00
Maurice 77162aa8d5 feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.

FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.

Four things the docs would have got wrong, all measured against the live instance:

- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
  and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
  shape drags every intersection along. So the adapter translates instead of borrowing a
  familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
  snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
  radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
  it is split into runs overlapping by one point, which is exact rather than approximate:
  a measured six-stop day came back within two metres and under a second of the same day
  asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
  those times feed the arrival clocks of a day. So a day is answered by one engine or by
  neither, never by both, and a piece that will not answer loses the whole day rather than
  half of it — the schedule chains leg times blindly and would otherwise print a complete,
  plausible, silently wrong timetable.

Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.

Two things that would have broken quietly and did not:

- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
  studio and the booking geometry. Without the avoidance in the key, flipping a switch
  returns the old road out of cache in under a millisecond — and worse, an avoiding answer
  stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
  echoes the coordinates it was given and never says where it bound them, but the ends of
  a leg's line are exactly those points: measured against Füssen, its first vertex matches
  OSRM's snapped location to the digit.

The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.

Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
2026-09-09 19:03:00 +02:00
Maurice 29fc586930 test(maps): pin the coordinates a suggestion carries
The half of the change that saves the pick from a text search for its own
label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there;
the client test writes the coordinates into its own mock and then asserts them.
Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the
fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎"
and taking whatever comes back first.

So the server test now asserts the coordinates on the wire for both indexes,
and the mobile sheet — which carries a copy of the same fallback and had no
test for it at all — gets one for the fallback and one for the source mark.
2026-09-09 18:55:31 +02:00
Maurice 7ba7338f5f fix(maps): sag bei jedem Vorschlag, welcher Index geantwortet hat
Seit die Tipp-Suche beide Schichten fragt, ist ihre Liste gemischt — die
Antwort nennt aber weiterhin EINE Quelle fuer die ganze Liste, und die heisst
'trek-places'. Der Client beschriftet damit jede Zeile, auch die aus
OpenStreetMap. Wer tippt, liest also acht Mal "TREK" und schliesst daraus, die
OSM-Schicht antworte gar nicht. Sie antwortet, sie steht nur unter falschem
Namen da. Auf der Suchstrecke daneben stimmt es laengst: dort traegt jeder Ort
sein eigenes `source`, und genau deshalb liest sich diese Liste richtig.

Also traegt es jetzt auch die Zeile. Das Feld ist optional, weil Google und der
Nominatim-Rueckfall je aus einer Quelle antworten; eine Zeile ohne eigene
Angabe faellt weiter auf den Namen der Liste zurueck.

Dazu die Koordinaten, die der Vorschlag ohnehin schon kennt. Bisher wurden sie
weggeworfen, und wenn der Details-Aufruf nicht antwortete — Kill-Switch, ein
ueberlasteter Overpass-Spiegel, irgendein Fehler oben — baute der Client eine
Textsuche aus Name und zweiter Zeile. Bei einer OSM-Zeile ist die zweite Zeile
aber keine Adresse, sondern der ortsuebliche Name: gesucht wurde dann nach
"Tokio Hauptbahnhof, 東京駅丸の内駅舎", und dessen erstes Ergebnis wurde
kommentarlos als der Ort genommen, den der Nutzer angeklickt hatte. Jetzt
nimmt der Rueckfall den Punkt, den die Zeile mitbringt.

Und das Abzeichen gibt es endlich auch mobil. Die Marke und ihre Tabelle
liegen dafuer in client/src/utils/placeSource.ts, weil beide Oberflaechen
dieselben Listen zeigen und ein Zeichen, das nur auf einer davon steht,
schlimmer ist als keines.

Was hier NICHT drin ist, obwohl es dazugehoert: dass der richtige Bahnhof in
der Liste steht. Das liegt am Dienst, nicht an TREK, und ist dort gemessen und
behoben — Rangfolge nach Art des Ortes plus die Verkehrsknoten, die der
OSM-Schicht ganz fehlten.
2026-09-09 17:34:05 +02:00
Maurice 2f71549413 fix(maps): let the imagery through the CSP, so the GL switch does something
The satellite toggle has worked on Leaflet for as long as it has existed and did
nothing at all on a GL map, which is the default renderer since the vector basemap
landed. Not for want of the layer: connect-src never named server.arcgisonline.com.

Leaflet asks for a tile as an <img>, and img-src waves every https: host through,
so the gap could sit there unnoticed for as long as Leaflet was the only renderer
that showed the imagery. A GL map reads the raster through fetch to hand it to
WebGL, and that lands on connect-src instead, where the host is missing. The
browser refuses it in the document, which is the worst shape a failure can take
here: the switch flips, the layer goes on, the source is there, and no tile ever
arrives. Nothing in the app can see it either, because a CSP refusal is not an
error the fetch can catch.

The prefetcher asks for the same tiles the same way, with mode 'no-cors', which
relaxes CORS and not CSP, so the imagery was never cached for offline use either.

Same shape as #2180, which added the other two raster presets for exactly this
reason and did not reach this host.
2026-09-09 16:57:10 +02:00
Maurice fab3d2bdf8 fix(maps): ask the OpenStreetMap layer on the autocomplete path
Typing "Tokio station" from Tokyo suggested a weigh station in Ritzville and a
station in Tuxtla Gutiérrez, and typing "Tokyo Station" put Japan in seventh
place behind Markham, Arlington, Calgary, Milano and Melbourne. The station
itself was never in the list.

The service has had the answer all along. Its OpenStreetMap layer carries 14.6M
travel places with every name form each is known under, including the exonyms a
traveller actually types, and it is not the same data as the index: Overture
holds businesses, so a search for a station finds the shops inside it. The layer
returns "Tokio Hauptbahnhof" for the German spelling and 東京駅 for the Japanese.

It was never asked. `sources` defaults to the index alone and trekPlacesSearch
never sent the parameter, so no caller here has ever seen a layer row.

Sent on the autocomplete path only. The explicit search asks Nominatim in
parallel and would get the same OpenStreetMap places twice; this path asks
nobody else, because Nominatim's usage policy names autocomplete as
unacceptable use. So the layer is not a second opinion here, it is the only
place those names live.

A layer row has a different shape, and that shape is why this is more than one
parameter. It has no `gers` and no address block, and its id reads
`osm:node/9712313` where this file resolves `node:9712313`. Passing it through
untranslated would hand the client an id getPlaceDetails does not know, and the
failure would land after the user had already picked the suggestion. The
secondary line falls back to the local name, which is what is written on the
building and more use under a translated label than an empty line.

Measured against the service before the change, same query, same coordinates:

  "Tokio station"  index only  -> Tokio Weigh Station, Ritzville US
                   with layer  -> Tokio Hauptbahnhof in second place
  "東京駅"          index only  -> six restaurants inside the station
                   with layer  -> 東京駅 itself in the list
2026-09-09 16:06:19 +02:00
Maurice 00cac02665 feat(maps): satellite on the GL maps too, not just Leaflet
The imagery toggle has been there for a while and only ever worked on Leaflet, so
anyone on MapLibre or Mapbox — which is the default since the vector basemap landed —
had a switch they could not see and a view they could not reach.

Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a
style with dozens of layers in it. So the same tokenless ESRI source goes on as a
raster layer of its own, inserted before the first layer TREK draws, which leaves the
route, the pins and the tracks on top and legible. Switching back hides it rather than
tearing it down, so going to and fro does not re-fetch a tile.

Built on demand rather than once at load, because a basemap change rebuilds the style
from scratch and takes every source with it; the styledata event is subscribed for the
same reason. The switch reads the setting the Leaflet map already stores, so the choice
survives an instance changing renderer, and it sits in the same corner with the same
offsets.
2026-09-09 15:57:46 +02:00
Maurice 756e0fb908 fix(places): let the pool follow the open day, and say that it does
The map has narrowed to the selected day on the Planned filter since #2024. The pool
beside it did not, so a trip with 55 planned places read 55 in the tab and drew five
pins on the map, with nothing anywhere to say the two were answering different
questions. Reported twice in the same thread as the map being broken: you pick a day,
open Places, tap Planned, see everything you ever planned, and the map shows a handful
of it.

The list and the tab count now use the same set the map does. Unplanned deliberately
stays trip-wide: a place assigned to some other day is planned, whichever day happens
to be open.

A note under the tabs says so and closes the day when dismissed. It sits there rather
than over the map because a chip on the canvas is out of reach on a phone, which is
where this was reported from.
2026-09-09 15:50:47 +02:00
Maurice d30b916f9c feat(maps): count how much routing an instance actually does
Every route is calculated in the browser against the public FOSSGIS hosts, so the
server never sees a routing request and nobody has a number: not how many a real trip
costs, not how big they are, not how much already goes to a self-hosted engine.
Deciding whether TREK could run its own router without that number is guessing at the
bill, and the counters have to be collecting before the question comes up.

Counters, not a log. A row is a day, a profile, a request kind and an engine kind with
totals on it: how many requests, how many waypoints, roughly how far, how many were
refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the
instance: its own database holds them and its own admin reads them at
GET /api/route-usage/summary.

The client tallies in memory and flushes in batches — on a timer, past a threshold and
when the page is hidden — because a counter that cost a request of its own would
measure the load by adding to it. Routing all funnels through one fetch in
RouteCalculator, so that is the only place instrumented.

On by default and switchable off with route_usage_enabled, unlike the shadow log next
door: that one stores what people typed, this one stores four integers about a day.
2026-09-09 14:52:50 +02:00
Maurice 88129e4546 feat(maps): search the view in road trip mode too
The category pill was switched off whenever road trip mode was on, which left the
corridor search as the only way to find anything. The two answer different questions
though — 'what is along this drive' against 'what is in view right now' — and looking
for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation
at a stop for the same reason (disc #1797 thread).

Both searches now draw together. A place found by both appears once, as the corridor
knows it, because only that copy carries how far along the drive it sits, and the
merged list keeps its reference while nothing changes so the map does not rebuild
every pin on each render.
2026-09-09 14:52:50 +02:00
Maurice a46d3fe88a fix(i18n): reword three literal German road trip strings
'Wähle aus, was du brauchst, und suche' is a word-for-word rendering of 'Pick what
you need and search' that no German speaker would write; 'Wieder ein Ziel' is not a
sentence a button can carry; and 'Alle Leistungen' reads as services rather than
charging power in a dropdown of kilowatts.
2026-09-09 14:52:50 +02:00
Maurice 149b87fd47 feat(roadtrip): give the day boundary its own band, and flag a late pause
Three things the rail was quietly getting wrong at a day boundary.

A stop reached later than the time set on it showed a warning only when it carried
a number. The schedule restarts its chain at any anchor, whatever kind of stop holds
it, so the finding was computed for a fuel or charging halt too and then thrown away.
Both branches now draw it, from one component, and it sits in the badge row beside
the stay rather than on a line of its own.

The midnight crossing was a small pill floating in the middle of the rail, which read
as a badge belonging to the stop underneath. It is now a band across the full width
that breaks the dashed line instead of letting it run through, with the mascot asleep
in the gap. Both its animations stop under prefers-reduced-motion.

And the stay dialog said 'leave 01:00' for a departure the chain places on the next
day, because the clock wraps modulo 24 h. It now carries the same day marker the rail
has always shown beside such a time.
2026-09-09 14:52:50 +02:00
Maurice 15beb0ea40 feat(roadtrip): work back from the first pinned time
The schedule only ever walked forward, so a stop before the first pinned time stayed
blank. That is the wrong way round for how people plan: the museum opens at ten, so
when do we have to leave? Pinning the time on the second stop and reading the first
one's departure off the chain is the whole point of pinning it.

The chain now runs both ways — forward from the first known arrival as before,
backward for everything ahead of it, departure being the next arrival minus the
drive and arrival that minus the stay. It stops at a leg that never routed rather
than inventing a duration, and when working back crosses midnight the whole chain
shifts up a day so the earliest stop stays on day zero and the boundary is drawn
between the two stops instead of under the first one.
2026-09-09 14:52:50 +02:00
Maurice f283254bb0 fix(reservations): keep a car booking's stops when it is saved on a phone
The desktop form has offered stops between pick-up and return since the road trip
release (#1797). The phone sheet never knew about them: its car branch rebuilt the
endpoint list from from/to alone, and because the server replaces every endpoint row
of a booking, saving there silently dropped whatever the desktop had set.

Its car branch now reads the stop endpoints back into an editor and writes them out
again, the same shape TransportModal uses: location, time, reorder arrows, remove.
2026-09-09 14:52:50 +02:00
Maurice 8acfaea3c4 chore(deps): take the multer and nodemailer fixes for four HIGH CVEs
Docker Scout turned the release PR red overnight on advisories published
after the last green run:

  multer < 2.3.0      CVE-2026-82333, CVE-2026-77078, CVE-2026-77037
  nodemailer < 9.1.0  GHSA-2x7j-588g-ccc2

Both are patch-level resource-exhaustion fixes with no API change — multer
2.3.0 carries the same four dependencies as 2.2.0. multer resolves through
the root override (@nestjs/platform-express asks for 2.1.1), so the range
there has to move with it, and the server's own override list beside it.
2026-09-09 10:24:22 +02:00
Maurice a463246c1c fix(tests): let the spool handler stub carry a payload
`ReturnType<typeof of>` resolves against the zero-argument overload, so the
stub only accepted `Observable<never>` and SPOOL-001 could not hand it the
`of('ok')` it needs. tsc -p tsconfig.tests.json broke on it, which sank the
server preflight job and with it every coverage job that needs it.
2026-09-09 10:11:36 +02:00
Maurice 2a38e79431 test(collab): cover the image upload path and the spool cleanup
The chat images never reached a test: committing them to storage, taking them
back out when a later one fails, and dropping them again when the reply target
turns out to be gone. The interceptor that removes the spool when a request
dies before the handler had none either, and it sits in a domain pinned at 99%
function coverage. Both filters are pinned too, including the disguised
extensions that were the point of changing them.
2026-09-09 00:11:00 +02:00
Maurice 2d2f1e88bd fix(collab): sync shared links live, and teach the harnesses the new columns
The three collab:link events were broadcast and landed nowhere: the links
panel held its own list and never listened, so a link somebody else added
showed up on the next reload. It listens now, the same shape the notes panel
uses, and the events are classified in the client's WS policy, which is the
ratchet that caught this.

Four e2e harnesses build their own trip_files and one asserts the collab
feature set, so the new message_id column and the new links flag had to reach
them too. Two MCP unit tests pinned the old four-key feature object.
2026-09-09 00:11:00 +02:00
Maurice 1e2a5e3862 fix(shared): keep the link contract free of DOM globals
The URL check used `new URL(...)`, and this package compiles against lib
ES2022 alone so that it carries neither DOM nor Node globals, which is why the
shared typecheck refused it. `z.url()` plus a protocol test does the same job
with names the package actually has; z.url() on its own would take mailto: and
javascript:.

The three collab:link events also needed their fixtures and their place in the
pinned inventory count, the guard that stops the fixture map drifting from the
registry.
2026-09-09 00:11:00 +02:00
Maurice 7e3b9fd039 fix(collab): keep every panel on screen, and stop leaking spool and links
The desktop layout rendered notes and links as a pair and dropped whichever of
the two was on its own, so turning the new Links feature off on an otherwise
default install made the Notes panel disappear. Both rows are now split by kind
and rendered only when they hold something, and one function decides what a
panel id renders, because the branch that lost a panel did so by not listing it.

The message and its attachment rows are written in one transaction. The caller
has already committed the image bytes by then, so a message that landed without
them left those bytes with nothing pointing at them and nothing to sweep them.
Deleting one now drops the blobs after the rows are gone rather than before.

A body the Zod pipe rejects used to leave up to four spooled images on disk:
pipes run after interceptors, so multer had already written them and the
handler's own cleanup was never reached. An interceptor removes them on any
failure before the handler, which also covers every other schema mismatch, and
the length check that could never fire is gone.

Adding, pinning and deleting a link now says something when it fails instead of
raising an unhandled rejection, the list has real types, and the rejected-image
toast is translated in all 23 locales rather than shipping English everywhere.
The picking, previewing and clearing of chat images moved into one hook both
shells use: it was copied into two, and both rebuilt every preview on each
change and leaked an object URL per image per pick.

Tests: the four link routes end to end with the validation pipe the harness was
missing, the image filter against the disguised-extension case, and the layout
combinations that drop a panel.
2026-09-09 00:11:00 +02:00
Maurice 07bb899c50 fix(collab): close the image upload hole and the guard trap
The chat image filter believed the Content-Type the client put on the part.
The stored name keeps the extension of the name the client sent, and the
download route derives what it serves from that extension and sends it inline,
so pwn.html declared as image/png came back as HTML on our own origin. The
filter now checks the extension as well, against the blocked list and against
the types it claims to accept, because a per-route filter fully replaces the
module-level one it looked like it was adding to.

The multipart message route also kept its guard decorators. Guards run before
the interceptor, so a refusal went out while the client was still streaming up
to four images and the socket died as ECONNRESET rather than carrying the
error. The checks moved into the handler, where the other upload route in this
file already does them, and a test pins both refusals.

The three collab:link events were missing from the shared registry, which the
server typecheck refuses, and the admin Links switch could never be saved
because the Zod contract had no key for it. Both are in now. Migrations moved
to the end of the list and the column add uses the pragma guard rather than a
caught duplicate-column error.

The message-required string keeps its old wording for a request without a file
part, so a client from before this route took images sees what it always saw.
2026-09-09 00:11:00 +02:00
Godric Hood 9543fdc3f2 feat(collab): add shared links, chat images, and plugin fullscreen
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
2026-09-09 00:11:00 +02:00
Maurice 59a1756529 style(admin): lay the admin panel out in two columns and match the banner buttons
The User Defaults and Notifications tabs were one narrow column of cards on a
page that has no max width, so the right two thirds stayed empty while the page
scrolled for ages. Both now use the two-column grid the Settings tab already
has, grouped by subject rather than by height.

User Defaults splits into "Default User Settings" (colour mode, units, time
format, currency, booking codes) and a "Map" card holding template, keys,
routing base, preview and engine. The columns are deliberately uneven — the left
one never needs more than its widest option row, the right one holds four fields
whose values are 60-character tile URLs.

Notifications keeps the three channel switches together on the left, because they
all write the same notification_channels list, and puts the operator's own
targets on the right. The admin preference matrix moves in with them instead of
running full width underneath: it decides which of those channels each admin-only
event goes out over. Its label column gets minmax(0,1fr) so it wraps rather than
overflowing in the narrower column.

The transit provider was the one native <select> left on the Settings tab. The
option list of a native select is drawn by the OS, so it followed neither the
colour scheme nor the radius nor the user's text-size setting; it now uses the
CustomSelect the rest of the app uses, and skips the request when the option
already selected is picked again.

In the update banner, "View on GitHub" was a size smaller than "How to Update" on
every axis — 12px against 14px, a 14px icon against 16px, less padding, and 2px
shorter. Two buttons side by side read as one group, so that reads as a mistake
rather than as hierarchy. Same box now; the fill, the colour and the weight still
mark the primary action.
2026-09-09 00:09:34 +02:00
Maurice 4611f0ab7a test(files): give the e2e harness the column the receipt link adds
listFiles projects linked_budget_item_ids now, so the harness table it selects
from needs budget_item_id or every list call in that file answers 500. Same
gap the collab harness had.
2026-09-08 23:15:26 +02:00
Maurice 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
dersimoezdag 06ecc5135c feat(budget): attach receipts and invoices to expenses 2026-09-08 23:15:26 +02:00
Maurice b2a196d1e7 style(planner): soften the empty-day add button
A solid outline and a whisper of accent instead of the dashed grey placeholder
it replaced, so an empty day reads as an offer rather than as something
switched off, without competing with the day rows around it.
2026-09-08 20:41:57 +02:00
Maurice f5d6af9c77 fix(planner): steady satellite pill, responsive places rail, day-scoped add
The satellite toggle sat on an offset that included --day-panel-h, so it hopped
every time the day panel was collapsed or expanded. On the desktop map that
panel is a centred card between the sidebars and never reaches the pill at the
left edge, so the lift only applies on a phone now.

In the places rail the two add buttons split the width evenly once a day is
open, and every button drops to its icon when the rail is dragged narrow. The
filter tabs span the rail and keep their own text widths. The count is a badge
across the rail; the search field got the outline the controls around it use.

An empty day offers a button that opens the place form pointed at that day
instead of only stating that nothing is planned. The day is cleared again when
the form is opened to edit, which it was not before.

The trip tab bar no longer carries a native title repeating its own label; the
full name moved to aria-label for the tabs whose label is abbreviated.

Three maps tests drove the index switch through app_settings, which no longer
holds it, and now move TREK_PLACES_ENABLED instead.
2026-09-08 20:41:57 +02:00
Maurice 9fa0ceaf49 feat(places): add a place straight into the day that is open
The places pool sits one click away from the plan, and a place you already know
belongs to today had to go through the day picker afterwards to get there. With
a day open, the add button splits: the left half still opens an empty form, the
right half opens one that assigns the place to that day on save.

The plumbing was already there. placeFormDayId has assigned a freshly created
place to a day since #1998; nothing in the places rail ever set it.

The main label shortens to "New place" while the split is out, so both buttons
fit side by side in the rail without either one truncating. The second button
stays mounted and collapsed rather than unmounted, so it has something to
animate out of: width, padding and opacity over 180ms, and nothing at all under
prefers-reduced-motion. Collapsed it is also out of the tab order and hidden
from screen readers, because a zero-width button is still focusable otherwise.

Both columns of the place form are 320px now. Two panels of different widths
flanking one form read as a mistake rather than as a hierarchy, and neither of
them is the more important one.
2026-09-08 20:41:57 +02:00
Maurice 900e4806ad fix(places): centre the empty states, and give the details column one too
Five states, one shape. EmptyState grows a `fill` option: the stack already
centres what it holds, but with no height of its own there was nothing to centre
inside, so every one of them sat pinned under the header of its column.

Optically centred rather than geometrically. The extra room goes underneath,
which puts the mascot slightly above the middle: in a column you can scroll,
dead centre reads as having fallen to the bottom the moment the list is short.

The details column's own placeholder joins them, with the idle look rather than
the sad one, because nothing being picked yet is a state and not a fault. Its
line is shortened to fit on one: "Pick a result to see more", in all 23
languages.

The corridor panel, the saved-places list and the place details all dropped
`compact`, so the five now read at the same size as the road trip rail.
2026-09-08 20:41:57 +02:00
Maurice 4a88dbcbcf fix(places): say where a result came from, and stop asking questions in the form
Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
2026-09-08 20:41:57 +02:00
Maurice 1761f2bcd5 fix(maps): keep a browser without WebGL out of the error screen
The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
its WebGL context inside the Map constructor. A browser that refuses one throws
from there, and VectorBasemap attaches the layer inside an async effect, so the
throw became an unhandled rejection no boundary could see.

What actually took the page down was the teardown. Leaflet registers a layer
before onAdd runs, so the half-built one stayed on the map, and the bridge's
onRemove calls this._glMap.remove() without asking whether there is one. That
TypeError lands in a React cleanup, which is why the error appears on the way
out of a map rather than on the way in, and why it reads as "Something went
wrong" when a tab changes and "TREK could not start" when the route does.

The browser is now asked first, once per page load, with a one-pixel probe that
hands the context straight back. Without WebGL the basemap is OSM raster tiles
instead of an empty map, and the maplibre chunk, about a megabyte, is never
fetched. The teardown path is safe either way: a half-built GL layer gets a
no-op stand-in so the bridge can finish its own cleanup, and detaching never
throws, because unmounting a map must not fail just because its basemap did.

Reproduced against demo.liketrek.com with WebGL switched off, and verified with
the fix: 14 raster tiles drawn, no GL canvas, no error screen.

Closes #2288
2026-09-08 19:00:13 +02:00
jelly 8be594d34c fix(atlas): correct Guangdong Province name in region bundle 2026-09-08 16:36:47 +02:00
Luffy 5ac6ce40be fix(i18n): add translations for new notification event labels 2026-09-08 16:19:31 +02:00
Maurice 2d7889c237 fix(memories): give the photo picker a chronological order it can rely on
The picker got its photos back in whatever order the provider happened to
return them. It groups by day and appends pages lazily, so an unordered page
lands under the wrong heading and pushes photos out of the fold the reader
is looking at.

Immich now asks for order: desc on both the search and the album path. An
older server that does not know the property drops it silently rather than
failing, so each page is sorted again here; the album paths drain every page
before sorting and are covered outright. getAssetInfo reports mediaType, the
same mapping the listing already used, so the detail route and the picker
agree on what an asset is.

Synology gets no sort parameter upstream on either path. Search.Search
documents none, and _fetchSynologyJson maps every app code except 106/107/119
onto a 400, so a parameter one DSM build rejects would take listing down
entirely for that user. Both paths read every page anyway, so ordering the
mapped result costs nothing and cannot fail.

No media type for Synology yet, deliberately. streamSynologyAsset serves
every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is
no video byte path. Labelling an asset a video would make the lightbox mount
a player on a still image, which is worse than the missing badge. The badge,
the byte path and the persisted media_type belong together in their own
change.

Five album cases in the Immich integration suite pinned the old arrival
order. Where the position was incidental they now look the asset up by id;
where the order is the point they state the chronological one.
2026-09-08 15:24:21 +02:00
Maurice cfae0a1f78 fix(journey): stop the load-more observer rebuilding itself on every render
ScrollTrigger kept onVisible in its effect dependencies, and both callers hand
in a fresh arrow on every render: the picker its loadMorePhotos, the gallery
its setVisibleCount step. So every unrelated re-render, a photo being ticked
included, disconnected the IntersectionObserver and observed again, and each
observe() replays the initial callback.

The callback moves into a ref. loading stays a dependency, because re-observing
once a page has landed is the only thing that still produces a callback when
the sentinel never left the 200px margin, so folding that into a ref too would
leave paging dead after the first page. FE-COMP-JSCROLLTRIG-009 pins that, 007
and 008 pin the rebuild itself; both are red on the old component.

Markup, classes and the signature are untouched.
2026-09-08 15:09:51 +02:00
Maurice 31061751b0 fix(client): sort the picker grid in a memo again, not inside the fetches
Sorting inside searchPhotos and loadAlbumPhotos looks equivalent and is not.

contextLocation is live editor state. The place search and "use my location"
write it while the picker stays mounted, and the picker key is only provider
plus date, so nothing remounts it. Open an entry with no location, let the
photos load, then search for Rome: the caption flips to nearest-first and the
grid does not move.

The appending search made it worse than that. It sorted only the page it had
just fetched before appending, so from page two on the grid was a sequence of
sorted blocks rather than a sorted list, and a photo from page two landed under
a day heading page one had already closed.

FE-JRN-PICKER-021 pins it and is red without the memo.

The video badge also gets z-10 and pointer-events-none. It shares a stacking
context with the city gradient that follows it, so the later sibling was
winning on the geotagged tiles; pointer-events-none is what the eight existing
video overlays in the journey code all set.
2026-09-08 14:58:07 +02:00
xvilo 559b7624d0 fix(client): show pending state on Immich picker Add button
Clicking Add gave no feedback while the async onAdd call was in
flight, and nothing stopped a second click from firing a duplicate
submit. Adds a local pending flag that disables the button and swaps
its label for a spinner until the call resolves.
2026-09-08 14:58:07 +02:00
xvilo 2de9de8c3a feat(client): add video indicator to Immich picker thumbnails
Picker tiles gave no visual cue that an asset was a video rather than
a photo. Adds a small play-icon badge (bottom-left corner, matching
the desktop/mobile gallery grid's video treatment) driven by the
asset's existing mediaType field. No video preview -- just the
indicator.
2026-09-08 14:58:07 +02:00
xvilo d609aefe8f fix(client): keep Immich picker photo order stable and chronological by default
sortProviderPhotos previously passed photos through unchanged whenever
there was no valid journey location, leaving order entirely up to
Immich's search API (not guaranteed chronological). It now falls back
to newest-taken-first.

The picker also re-sorted the entire accumulated photo list on every
render, so loading another page could reorder tiles already on screen
when a location context was set -- newly loaded photos could land
above the fold instead of at the bottom, making it look like nothing
happened. Each fetched batch (search page or album) is now sorted once
at fetch time and appended, so earlier pages never get reshuffled by
later ones.
2026-09-08 14:58:07 +02:00
xvilo 1792fc17d0 style(client): run prettier on Immich picker files before functional changes
No logic change; isolates formatting noise from the upcoming picker fixes
so those diffs stay reviewable.
2026-09-08 14:58:07 +02:00
Maurice 72935d7e40 test(admin): wait for the migration POST instead of reading it after the toast
FE-MOB-MSTOR-013 read migrationBody straight after the save toast. The toast
comes from the PUT, the migration POST is fired by the queue effect one commit
later, so the two are not the same tick. It only passed while that effect
happened to flush first, which on a loaded runner it does not.

The desktop twin FE-ADMIN-STOR-030 has waited here since 8069b8805; the mobile
file was left behind.
2026-09-08 14:39:34 +02:00
Maurice 569f0ca1a5 test(mobile-nav): follow the new default dock through the mobile suites
Three expectations still described the Vacay/Atlas dock. Two of them
asserted the old arrangement by name, so they were pinning the very thing
that changed rather than catching a regression.

FE-MOB-NAV-006 now watches Atlas take the demoted seat next to
Collections, and picks Atlas out of the popover in FE-MOB-NAV-011 for
the same reason. Two new cases cover what the change is actually about:
Journey sits in the dock when it is enabled, and an instance without the
addon still gets Vacay and Atlas.
2026-09-08 14:39:34 +02:00
Maurice 5d0d106c70 chore: merge main into dev to clear the release conflicts
Nine files conflicted between the two branches and the merge changes not
one of them: the result is byte-identical to dev. Six were the version
string alone, 4.2.1 against 4.2.0, which the release job rewrites to
4.3.0 on the way to main anyway. The other three were places where dev
already carried main's change plus more, so dev's side wins in each.

Nothing is lost. Thirty of the thirty-four commits on main are
cherry-equivalent to dev already; of the rest, two are version bumps,
one is an empty tag commit, and the RTL text plugin fix is in dev under
its own commit, verified by the files it added rather than by its
message.

Also drops the copy of the LLM-ceiling test that main carried twice under
two names, which the merge would otherwise have reintroduced.
2026-09-07 23:26:18 +02:00
Maurice c04e740ff1 fix(mobile-nav): dock Journey by default, move Atlas one tap further
Feedback from Discord: on a phone in the field the two things you reach
for are the itinerary and the journal, but the default dock spends its
two free slots on Vacay and Atlas. Journey sits under More, and most
people never open the navbar customiser to fix that.

DEFAULT_DOCK_IDS becomes a priority list instead of a membership test:
the first MOBILE_NAV_MAX_BAR entries that are actually enabled take the
slots. Journey ships disabled, so an instance that never turned it on
keeps the exact Vacay/Atlas dock it has today. Listing all four known
global addons also fills the dock when one of them is switched off,
where the old membership test left the slot empty.

Accounts that saved their own bar/more split are untouched: a non-empty
config still wins over the default, so nobody's arrangement moves.
2026-09-07 23:18:23 +02:00
Maurice f1120be3c1 test(roadtrip): pin the via contracts
The schemas had no spec of their own. The two things worth pinning are the
ones the feature rests on: the anchor is a position and never an id, so a
fraction or a negative is not a leg of anything; and the batch route's
track has three states, because absent and null mean different things to a
day that already follows a line.
2026-09-07 18:36:54 +02:00
Maurice d3aad4c41e test(roadtrip): cover the planner half the mode switch unlocks
The road trip handlers in useTripPlanner are unreachable until the addon is
on for the instance and the mode is on for the trip, so the existing suite
never entered them: 360 of the file's new lines and branches had never run.

Turns both switches on and drives what the rail, the corridor panel and the
map call. The cases that matter are the ones where a via has to be
re-anchored: a via records which stop it follows by position, so a reorder,
a move between days or an insert that forgets the correction silently
re-shapes the drive.
2026-09-07 18:36:54 +02:00
Maurice 91801b10c0 test(roadtrip): cover the five client pieces that had no test
Five files, about 570 lines, that shipped without one. The cases are chosen for
what a later change could break quietly rather than for the line count.

useRouteAlternatives carries the decision the feature rests on: when a leg
already has vias, the road actually being driven is NOT among what the router
offers for the two bare endpoints, because it was asked a different question. It
is fetched separately and put at the top — without that, opening the picker on a
leg you have reshaped shows three roads, none of which is the one you are on and
no way back to it. Also pinned: a via that barely moves the route produces one
entry rather than two, a second ask abandons the answer still in flight, and only
driving, walking and cycling ever reach the router.

RoadtripStayModal writes `places.duration_minutes`, which the schedule has read
for years and nothing could write. The case that matters is that clearing sends a
zero and never a null: the update statement folds a null into "leave it alone",
so a null could give a stop a stay and never take one away. The clock cases pin
the thing a slider is for over a text box, which is watching the departure move.

StopKindPicker is a portal, so it gets the cases a portal makes easy to get
wrong: nothing without an anchor, outside-click and Escape both closing, the
anchor itself not counting as outside, and picking the kind a stop already has
taking it back off rather than setting it twice.

RoadtripCategoryPicker stays open on a click, which is the whole reason it is
multi-select, and names the kinds rather than counting them.

RoadtripAlternativesBar reports each offer by its own index rather than by its
position — a position drifts the moment the driven road is prepended — and shows
loading, failed and "only one way" as three different answers instead of an
empty list.
2026-09-07 18:36:54 +02:00
Maurice b2d9b25bb6 fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its
neighbour.

The driving limits saved on every keystroke. Typing "180" sent three settings
writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so
whichever the server committed last won: the number that decides every
over-budget warning could quietly end up a tenth of what was typed, and only
show it after the next reload. Typed locally now, committed on blur or Enter,
and a value that did not actually change writes nothing.

The empty-result message always blamed the name box. Four controls narrow that
list and only one of them is the name, so picking a section with no hits
produced `Nothing on the way matches ""` — empty quotes naming a filter the
reader never set. The count line above it was gated the same way, so the section
case lost its "x of y" as well. Both follow whatever actually narrowed the list.

The check-out dropdown printed a bare ISO date, the only place in the planner
that did; it goes through the same helper as the rail beside it.

Switching an addon off left open MCP sessions holding its write tools. A `when:`
gate runs once per session, at attach time, so the tools stay registered and
callable while the REST half answers 404 for the same user in the same moment —
and the list that decides whether to tear those sessions down had drifted:
airtrail and collections gate tools and were missing from it, which this addon
would have inherited. The list now sits beside ADDON_IDS and a parity test reads
the gates out of the source and holds it there. Read out of the source rather
than out of a runtime registry on purpose: a runtime set only holds the gates
whose modules the caller happened to import, so it would pass in a suite that
imports none — which is the shape of test that hid this in the first place.

RoadtripLimitsCard gets the test it never had, including the one case that
matters: three keystrokes are not three writes.
2026-09-07 18:36:54 +02:00
Maurice 44d52ab215 fix(roadtrip): stop one gesture writing twice, and one search running too early
Five things, all of them a click or a drag doing something the traveller did not
ask for.

The GL via handle committed a move on every pointerup. Any button started the
drag and there was no movement threshold, so a plain click wrote the via to
wherever inside the 17px handle the pointer happened to be — at a low zoom that
is kilometres, and the write re-routes the whole trip. A right-click ran the
drag AND the delete, so one gesture sent two writes; on the platforms where
contextmenu comes first, the move landed on an id that no longer existed and
reported a failure for a deletion that had worked. Leaflet gets this right for
free from `dragend`, which is why the two renderers behaved differently for the
same gesture. Primary button only now, with a threshold, and pointercancel puts
the pin back instead of committing — the browser fires it when the captured
element is torn out of the DOM, which is exactly what a delete does.

With the alternatives picker open, one click on the route fired both handlers.
Layer registrations are independent in MapLibre, and every alternative starts
and ends at the same two stops — when the leg already carries vias the current
road is itself one of the offers, so the overlap is the whole leg. The click
dropped a via AND chose a different road: two writes and two trip reloads for a
gesture made once.

The corridor search could be started before the day had routed. The corridor is
then built from the straight line between the stops rather than the roads
driven, and the routing answer landing a second later changes the line, which
clears the search mid-flight and drops every result — no error, no partial
result, just the idle empty state again. Routing runs one day at a time about a
second apart, so the window is wide open on a long trip.

Leaving road trip mode left the alternatives drawn on the map. The switch is in
the sidebar and reachable while the bar is open, and the overlay depends only on
the picker, so pale blue routes with drive-time pills stayed on an ordinary
planner map with no road trip UI left to dismiss them from.

Clearing a leg's vias swallowed its failure and closed the picker anyway. Every
neighbouring write in that hook reports one. The leg still carried its via and
still routed the old way, so the traveller believed they had undone the detour.

Walking and cycling legs were counted as driving. A day may legitimately mix
modes — splitIntoRuns exists for that — and a two hour hike was reported as
"over the longest drive allowed", its minutes went into the day's driving time
and its kilometres onto the tank, where a ferry crossing could trip a range
warning outright. A leg with no mode still counts, because every leg was a drive
before the field existed.

The road-trip domain also says out loud that it is online-only, which the repo
asks for rather than leaving it to be discovered. A via anchors by POSITION, so a
queued write replayed after somebody else moved a stop would re-pin the drive
onto a leg nobody chose, silently and hours later; the offline story needs the
anchor to be an assignment id first. Until then the surfaces disable the gesture
instead of accepting one and dropping it.

Six theme-lint markers move onto the line they exempt. The linter suppresses per
line, so a marker in the comment block above suppressed nothing and the six
road-signage colours the authors documented as intentional were counted anyway.
2026-09-07 18:36:54 +02:00
Maurice 239856f35c fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written
to their personal settings row. The CSP is built from something else: bootstrap
reads the ADMIN default and puts that origin into `connect-src`. So filling the
field in was the one path the product offered, and it could not work — the route
calculator happily built its URLs against the typed origin and the browser
refused every one of them with a bare TypeError, not a status, so no fallback and
no retry could see it. Day-plan routes, transport legs, the mobile day route,
Studio's road route and the whole road-trip rail stopped routing, app-wide, with
a generic "route could not be calculated". Nothing in the UI could repair it
except emptying the field again, and there was no admin field to set the value
that would have made the origin allowed.

The repo has decided this shape once already: `llm_base_url` is admin-only
because it names where the server sends its own requests and only whoever runs
the instance can judge what is reachable. This is the same class for a different
reason, so it joins the same guard, which is now named for what it actually
covers. Clearing stays open to everyone, so a row written before the rule cleans
itself up rather than blocking the save.

The field moves to the admin's default-user settings, next to the tile URL and
the Carto key, where a value takes effect.
2026-09-07 18:36:54 +02:00
Maurice 65be8db122 test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No
controller test, no e2e, and no MCP test — so the guard chain, the trip scoping
on every route, the cross-trip track check and the day_edit parity were all
unasserted, and the whole domain sat in the 80 catch-all where it could shed
twenty points against the headroom of the domains beside it.

The e2e is the one that earns its place. `@RequireAddon` is metadata and does
nothing on its own, and AddonGuard is not global, so the ordering the
controller's own comment calls load-bearing is exactly the kind of thing a later
change removes without noticing — it had already happened once. Deleting the
guard now turns three cases red, which I checked by deleting it.

The MCP tools had never executed at all: the shared harness seeds the addon
disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run
against the same checks the REST routes make, including the demo account and a
member without day_edit, because the repo requires the two surfaces to move
together.

Two client modules that had no test get one: the overlay builder, where two
decisions live that are not cosmetic — which offered road is drawn as the one you
are on, and that a label hangs where its own route is unlike every other, rather
than at a midpoint that can fall on a stretch two routes share — and the service
marker, where the thing worth pinning is that an ordinary place gets nothing
back, and that the anchor arithmetic includes the border.

The domain carries its own coverage floor now instead of hiding in the catch-all.
Pinned under the measured figure on purpose; the exact ratchet gets regenerated
with the repo's own script after a full run.

Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every
other domain's tables are on. Without that a via written by one case was still
there for the next one, which is how the first version of the MCP suite passed
an assertion it should have failed.
2026-09-07 18:36:54 +02:00
Maurice 41e256bf0f feat(roadtrip): answer the corridor search from the place index
Search along the route went to Overpass and only to Overpass, on the one path
where that hurts most: one search fans out over sixteen boxes, and each of those
races four public mirrors. The index answers the map's explore pills in
milliseconds where those mirrors take seconds, and the corridor was the reason
the index exists.

Two independent things kept it out. The corridor sends its categories as a
comma-separated list, and the index branch looked the whole string up as one key,
so it missed every time. And the four kinds a drive needs, fuel, charging, rest
areas and campsites, had no Overture terms mapped at all. They do now:
gas_station and fueling_station, ev_charging_station, rest_stop, and campground
with rv_park.

The branch is list-aware the way searchOverpassPois already was, and it labels
each hit with the category that produced it rather than with the list that was
asked for, because the client colours and groups its markers by that field and
"fuel,charging" is not a category. It answers all-or-nothing: a category the
index has no terms for sends the whole query to Overpass, since answering the
half it knows would silently drop the rest while the panel still reported a
finished search. The caller's limit is passed through instead of a hardcoded 50,
so a mixed search gets the same per-category budget the Overpass path spends.

The other outbound path of this addon changes with it. Importing a shared Google
route geocoded its waypoints by calling searchNominatim directly, which walked
past geocodeQuery and its index lookup. Through geocodeQuery an index hit costs
no throttle slot at all, and what the index does not know still falls through on
the background lane.

A place answered from the index also carries its chain and that chain's Wikidata
item, so a branch gets its logo on the pin here exactly as it does on the
Overpass path. Sockets stay an OSM field: a charging station answered from the
index reports "not stated" rather than claiming it offers nothing.
2026-09-07 18:36:54 +02:00
Maurice 1e1dd9b814 fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a
day's stops moves the ground under it. The road-trip rail corrects for that;
almost nothing else did.

reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0
in each, and re-anchoring MERGES two legs whenever the stop between them goes
away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the
route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and
forward again. Persisted, surviving a reload, and unrepairable except by deleting
the vias. The old anchor is read before the updates and used as the primary sort
key, so the vias of the earlier leg stay ahead of the ones that merged into it.

Reordering the only two stops of a day deleted every via on it. A reorder is
modelled as remove-then-insert, and the removal half refuses to treat a two-stop
day as still having a drive — right when a stop really goes away, catastrophic
when nothing was removed. Two stops is exactly the shape "follow this track"
produces, up to nine vias on that one leg, gone to a single drag with no prompt
and no undo.

Removing a stop, reordering within a day, and assigning a place mid-day never
re-anchored at all, though the helpers exist and three other paths use them. The
detour then sat on a leg the traveller never chose, or on a leg that no longer
existed and vanished — until a later stop brought it back bending a road nobody
asked for. Reordering hands a whole new ordering rather than one move, so the
anchors there follow the stop they were pinned behind instead of arithmetic on
the index, which is correct for any permutation.

Duplicating a trip dropped all of it: `stop_type` was missing from the place
insert, and neither via nor day-track table was copied. The duplicate looked
complete and quietly drove somewhere else.

A failed read of the via list emptied it. That read runs after every write and
feeds the routing key, so a 502, a dropped connection or a tab going offline
re-routed the whole trip along the roads the traveller had steered away from,
with nothing said and nothing retried — indistinguishable from having deleted
them. Only a 404 or 403 empties now; anything else keeps the last known list and
says it may be stale.
2026-09-07 18:36:54 +02:00
Maurice c1f7587704 fix(roadtrip): a freshly placed via lost its first drag
Placing one triggers a re-route, the re-route lands about a second later, and
the render it causes rebuilt every via handle — which is exactly when somebody
reaches for the dot they just made. On the GL renderer that rebuild replaces
the DOM element outright, and a pointerdown belongs to the element it happened
on, so the drag went to an element that no longer existed. The second attempt
worked because by then nothing was pending, and an older via never showed it
because nothing was re-routing.

Two causes, both identity: the vias arrive as a fresh object on every reload
even when the rows came back identical, and `onMoveVia` is a fresh closure on
every render of the planner. Both were in the effect's dependencies.

The list is now keyed by what a handle is built from — id and position — so it
only changes when a via does, and the callbacks are read through a ref. A via
that genuinely moved still gets a new list, which is what redraws it where it
now is; the test pins both halves, because holding the element still must not
mean holding it in the wrong place.

Leaflet's markers had the same churn without the same symptom: a fresh
`position` array called `setLatLng` on every marker on every render, and a
fresh `eventHandlers` object unbound and rebound every listener. Same fix.
2026-09-07 18:36:54 +02:00
Maurice 676c82a308 feat(roadtrip): remember which track a day follows
The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
2026-09-07 18:36:54 +02:00
Maurice 6fca238440 feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not
extract list ID", which is the whole of the complaint: people share a drive
far more often than they share a list, and it is the same gesture — somebody
pressed Share in Google Maps. Which screen they were on is the URL's business,
so the box takes both and the controller decides.

No API key and no call to Google for the link itself: the stops are in the URL.
Three shapes arrive — the path form with its data blob, the documented
?api=1&origin= form, and a short link that is one of those after a redirect —
and a stop the link spells out in coordinates needs no geocoder at all. One
that is only a name is geocoded, keeping the name from the link rather than
the one the geocoder answers with: somebody who typed a nickname into Google
should not find a street address on their trip.

A name nobody can place is left out and counted rather than failing the
import, because a route of six stops with five findable is five stops more
than the traveller had. Coordinates are read as 1d/2d, longitude before
latitude, which is the reverse of a place link and the one way to put Berlin
in Somalia. A blob that does not line up with the stops is ignored entirely:
half a link read confidently is worse than one read as names.
2026-09-07 18:36:54 +02:00
Maurice 761ec4e66f feat(roadtrip): let a day drive along an imported track
An imported GPX track was a line drawn beside the day: the map showed the
scenic route while the day kept driving the motorway, so the distance, the
drive time and every warning belonged to a road nobody was taking. Norway
publishes its scenic routes that way, and so does every touring club worth
importing from.

A track becomes a route the day takes by refinement, not by conversion. Route
through what we have, find the point of the track the road misses by the most,
make that point a via, go again. Each round roughly halves the worst miss,
because the new via lands in the middle of what the last one left unfollowed,
so a scenic route usually fits inside ten vias — and the loop stops when the
road is close enough rather than after a fixed count. Every point of the track
would route exactly and take a hundred requests; two would route in seconds
and follow nothing.

The day is routed whole per round, not leg by leg: it is a single request
already, and per-leg would multiply every round by the number of legs against
a host that answers one request a second. The track is oriented to the day
first, because half of Norway's routes are published southbound, and the legs
it covers are cleared before the new chain lands, so applying a track twice
does not stack.

Offered from the day's own badges, and only once something has been imported.
Tinted while the day carries vias: the rail draws none of them, so that badge
is the only place a drive shaped by hand differs from one the router picked.
2026-09-07 18:36:54 +02:00
Maurice 620f4c6ecd fix(roadtrip): let the section filter shorten instead of overflowing
A nowrap label reports its whole width as its min-content contribution, so the
place-name select sat at its default `min-width: auto` and refused to shrink —
pushing the ± kilometres control past the panel's right edge instead of
ellipsing the name.

The menu that select opens now grows to its options rather than inheriting the
narrowed trigger, bounded by the panel it sits in so it never hangs over the
map: a list of identical shortened prefixes is not a list you can pick from.
2026-09-07 18:36:54 +02:00
Maurice 36ac5de84c feat(roadtrip): narrow the corridor to one stretch of the drive
A seven-hundred-kilometre day turns up seventy hits spread over the whole of
it, and the traveller wanting a break near Berlin has to read all seventy and
work out from "245 km along the route" which ones are anywhere near. That is
the wrong way round: you decide roughly where to stop, and then look at what is
there.

Picking a point narrows the list to what lies within 25, 50 or 100 km of it.
This is a subtraction rather than a second search, because the stops and the
hits are already measured along the same thinned line.

The midpoints of the legs are offered alongside the stops, and they are the
point of the feature: a break on a five-hour drive belongs in the middle of the
drive, not at either end. Asking only about stops would answer "what is near
Berlin" when the question is "what is near the halfway mark".

The chosen point is stored as which anchor it is rather than as a distance.
alongKm is measured against a line thinned with a tolerance derived from the
corridor width, so a kilometre figure kept here would quietly drift when the
width goes from 5 to 10; resolved through the stops it lands on the same place
again. It also names its day, so switching days cannot apply yesterday's break
to today's drive.
2026-09-07 18:36:54 +02:00
Maurice 061259b3d9 feat(maps): pass through what OSM knows about a charging station
The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.

The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.

Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.

The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
2026-09-07 18:36:54 +02:00
Maurice fc74991a15 feat(roadtrip): turn a place into a stop on the way from its own number
An ordinary place could not become a fuel stop without going through the full
form, and the number in the rail is exactly what changes when it does: a service
stop has none. So the number is the control. Click the 3, pick the pump, and the
3 becomes an orange disc while everything below renumbers itself. The disc of a
service stop opens the same picker the other way, with a row to make it an
ordinary destination again.

Six coloured discs rather than six lines of text: at that size the colour and
the shape are the label, and anyone who has seen one petrol stop on the map
knows the orange pump before reading anything.

Centred on the column rather than on the number, which sits hard against the
left edge and would hang the panel half over the map. The click stops at the
disc, so picking a kind never also selects the place underneath.

Also in this change, from the same round: the corridor search offers hotels, a
hit somebody could sleep at opens the popup in an overnight mode with a check-out
day instead of a dwell, and booking one writes the place, its day and the row in
day_accommodations together. A hotel keeps its number, because sleeping there
ends the day rather than interrupting the drive, which is why the category list
and the stop-kind list are now two lists instead of one.
2026-09-07 18:36:54 +02:00
Maurice 49f97af3fb fix(roadtrip): the range figure keeps counting until something actually refuels
Reported off a real day: Berlin said 245 km since the last fill-up and Sanssouci
said 121, as if the tank had been filled in between. It had not. The budget was
being zeroed at every warning, so the first figure was right and every one after
it was the length of one leg rather than the distance on the tank.

A warning is not a fill-up. Only a fuel or charging stop resets it now, which is
what makes putting one in the answer rather than a formality: the same day reads
125, 370, 519 without one, and collapses to a single mark with one.

Nothing is flagged on a fuel stop either. Reaching a petrol station on an empty
tank is the plan working, and a warning there would sit on the one stop that
answers it.

Also: the stop number sits level with the middle of its row rather than at the
top, the way the corridor list already places its badges. A number pinned to the
first line drifts off as soon as a stop carries a stay and a walk under its name.
2026-09-07 18:36:54 +02:00
Maurice 4215db2908 refactor(roadtrip): the limits as badges, and a quieter empty state
The three figures under the button were one run-on string joined by dots, which
says nothing about which number is which. Each takes the badge shell the rail
already uses, with its own icon: a clock, a calendar, a pump. The icon tile in
front of the title goes, because it was eating exactly the width the three
badges need to sit on one line.

Distances there go through formatDistance, so an imperial traveller reads miles
on the button and types miles in the dialog while storage stays kilometres.

EmptyState gains a compact variant. A sidebar state sits under its own controls,
where a line at page size competes with them; at the caption tier it reads as
what it is, a note about why the list below is blank.
2026-09-07 18:36:54 +02:00
Maurice 95c173a8a8 fix(roadtrip): put a driving finding on the stop it arrives at, not the one it leaves
Reported from a real trip: Hamburg, A1, HEM, Berlin. HEM to Berlin is 244.7 km,
and the badge on Berlin said 121 — which is the length of the NEXT leg, Berlin
to Sanssouci. The finding was anchored to the stop the leg departs from while
its wording ("since the last fill-up") is a running total, so the number never
matched what anyone adds up off the drive bands above it.

Both findings are true on arrival, so that is where they belong. The same trip
now reads 125 at HEM (21.5 + 103.4) and 245 at Berlin, which is exactly what you
get counting the bands.

A service stop carries them too. A charging halt is a stop like any other as far
as the tank is concerned, and HEM was the very stop whose figure went missing.

The corridor's empty state gets the mascot the rest of TREK uses: searching,
idle, and a confused look when a filter left nothing standing, so the picture
says which of the three it is before the sentence is read.
2026-09-07 18:36:54 +02:00
Maurice 282e3c6b13 refactor(roadtrip): TREK's own tooltips everywhere, and a visible edge on the warning badge
Eight native title attributes across the rail and the corridor panel, which
render as the browser's own grey box after its own delay and ignore every
appearance setting. All of them are the shared Tooltip now, the same one the
rest of the app uses.

The warning badge had its border in the soft tint of the warning colour, which
at sixteen pixels disappears into the card and left it as the only badge in the
row without a shell. It takes the warning colour itself.

Two tests moved with it: a tooltip only enters the DOM on hover, so what they
pin now is the visible difference between a pinned and a computed arrival —
weight and ink — rather than an attribute that no longer exists.
2026-09-07 18:36:54 +02:00
Maurice ea9de65550 feat(places): make the kind of stop editable, and enforce it on the way in
Three holes, one seam. Leaving the corridor popup by "more details" dropped the
kind and the dwell it had just worked out, so a fuel stop silently became a
numbered destination that counted in every total, in the badge and in the PDF.
Once set, the kind could never be changed back through the web UI at all. And
the REST route did not validate the field: both request schemas are open
objects, so stop_type: 'banana' was written straight to the column, where
isServiceStopType read it as false and the stop counted as a destination again.

The form now carries the field, so the popup's answer survives the trip through
it, an ordinary place can become a service stop, and a wrong kind can be taken
off. Editing keeps stop_type and deliberately leaves duration_minutes out: how
long a stay takes belongs to the rail's own dialog, and sending it from here
would overwrite what was set there.

The schemas gain the one field rather than closing: roughly 190 call sites post
whatever their form holds, and a closed object would reject all of them at once.
The doc comment claiming the list was already enforced is corrected — it was
describing an intention.

The two driving findings move into the stop's badge row in warning colours,
beside the stay and the walk from the road. All three say what this stop costs,
and one row reads as one answer rather than as notes stacked underneath.
2026-09-07 18:36:54 +02:00
Maurice e79a2591a1 refactor(roadtrip): put the driving limits behind a button, and the walk beside the stay
Two things the column was getting wrong. The limits sat open as a card, three
rows of a narrow sidebar for something set once on the first day of planning
and left alone after; they are behind a proper full-width button now, with what
is currently set riding on the button so the form does not have to be open to
read it. And the walk from the road was its own line under the stay, where it
looked like an unrelated note; it wears the stay's two-part badge and sits
beside it, because both answer the same question about a stop — one in time,
one in a walk.
2026-09-07 18:36:54 +02:00
Maurice dfb038d90f feat(roadtrip): say when a drive is longer than you drive
Roel and gfRally asked for three things: how far the car goes on a tank, how
long you are willing to sit behind the wheel in one go, and how much driving
belongs in a day. They are one feature. All three are a threshold, a walk over
the legs, and a finding.

Two things they deliberately are not. They are durations and distances rather
than times of day, because with no stop pinned to a clock the cascade produces
no times at all, for any stop, and a limit on an hour would simply never fire.
And the day limit is called driving time, not travel time, because the figure
it is measured against is exactly that: the badge beside it, which counts no
time spent at stops and no leg to a hotel. The card says so out loud rather
than leaving it to be discovered.

The range budget carries across midnight, since a tank does not empty
overnight, and resets after a finding: without that, 1800 km on a 600 km range
flags every leg after the first overrun. Only fuel and charging reset it, never
the other four service kinds — a two-hour lunch fills nothing, and nobody here
knows whether the restaurant has a charger in its car park.

Kilometres in storage, the traveller's own unit on screen. Skipping that round
trip means an imperial user types 400 meaning miles, 400 km is stored, and the
warnings arrive a third too early from then on.

No stop is suggested and no vehicle is modelled. OSM does not know what car
this is and neither do we, so the range is a number you type. Furkot does the
same, for the same reason.
2026-09-07 18:36:54 +02:00
Maurice 56266138c6 feat(roadtrip): show the halts a routing plugin puts on the drive
A plugin may already route a day in road trip mode, and the charging stops it
answered with were thrown away: the map was handed an empty list and the rail
never looked. That made "build it as a plugin" — the answer given to charging
prices, to ABRP and to the AFIR data — untrue in the one mode where it matters.

Each halt is measured along the run once and filed on the leg it is actually
driven on, using the leg lengths the router reported rather than by projecting
the stops too: leg distances are exact by construction, while two stops close
together project onto each other's stretch and would put a charge the wrong
side of a town.

The rail row is deliberately the poorest of the four. No number, no editable
stay, no arrival time, a hollow disc: the halt belongs to the provider, not to
the traveller, and everything filled on that rail is something they put there.
Nor is it ever written back — a plugin halt stored as a via would go out as a
waypoint on the next run, and the plugin would plan around its own charging
stop.

No time on the row for a second reason: the SDK contract folds the stop into
the leg duration, so the arrivals already account for it. Adding dwellSeconds
to the schedule would push every arrival after a charge back by the charging
time.
2026-09-07 18:36:54 +02:00
Maurice 11f4c740e1 feat(roadtrip): draw the last bit to a place the road does not reach
A router snaps every waypoint to the nearest road before it starts, and OSRM
does it with no distance limit at all. A viewpoint above a valley, a farmhouse
down a track, a marina at the end of a pier: the drive to it really begins
somewhere else, and the line said nothing about that. It either stopped short
for no visible reason or appeared to cut across open ground.

OSRM reports the snap position and its distance in every single answer, and
this client has thrown both away since the first route was drawn. Reading them
costs no request and no key.

The gap is drawn as a dashed spur in the route's own blue, over the route
rather than under it, because it is the one piece of the line that is not
driving. Thirty metres is the floor, which is about a dual carriageway and its
verge, so a place pinned off an aerial photo does not earn a dash. From a
quarter of a kilometre the rail prints the distance too, because that is where
"the car park is over there" turns into luggage, a gate, or a track a hire car
should not be on.

Vias are deliberately left out: a via is a shape handle, not a destination, and
a dash hanging off one reads as a fault.
2026-09-07 18:36:54 +02:00
Maurice d6eac772aa fix(maps): ask a router that refuses exclude only once
Both public hosts TREK ships with reject the parameter with HTTP 400: their car
profile is built without excludable classes, and only a self-hosted OSRM built
through the MLD pipeline has them. Every long leg was therefore paying two extra
requests, against a host that allows one per second, to be refused twice.

The first real attempt is the probe. Only a 400 is remembered, because only a
400 is about the host: a 429 is a rate limit, an abort is the user moving on,
and a NoRoute means this leg has no way round the motorway while the next one
might. Kept per base URL, so pointing the instance at an own router that does
support it starts asking again without a reload.

The comment above the fallback said none of this and read as a working feature,
which is why "only one sensible way to drive this" keeps appearing on legs that
plainly have another road.
2026-09-07 18:36:54 +02:00
Maurice 67163e7224 feat(roadtrip): lay a whole chain of via points in one write
The single-via route is right for a detour dragged by hand and wrong for
anything that derives its anchors from a line. Pinning a day onto a recorded
track or a signed scenic road means a couple of dozen points, and one request
each re-routes the entire trip once per point, spaced by the routing host's own
rate limit — twenty anchors would spend half a minute drawing routes nobody
asked to see.

replace_legs names the legs to clear rather than taking a flag for the day, and
that is the whole difference: a traveller who bent two legs by hand and then
adopts a scenic road for a third has not asked to lose the two.

The tools follow in the same change, as parity requires. Undoing a detour picks
this up too, where the vias of one leg were deleted one request at a time.
2026-09-07 18:36:54 +02:00
Maurice ba7bd1dd91 refactor(roadtrip): settle the routed-leg shapes before three features fill them
Three separate features write into the same two places here: the leg merge and
the final memo. The access lines want the snapped waypoints, plugin routing
wants the via points it was given, and the driving limits want findings that
belong to a leg rather than to a stop. Built one after another, the same two
lines get rewritten three times.

So the shapes land once, empty. RoutedLeg gains snapped and vias, RoadtripDay
gains driveWarnings and dayWarning, RoadtripRoutes gains accessLines, and the
memo becomes a walk rather than a map, because a range budget carried past
midnight needs somewhere to live between two days.

Schedule stays exactly as it was. Its own test compares the empty result with
toEqual, which checks both directions, so a third field there would fail it —
and a finding about the driving does not belong to a stop anyway.
2026-09-07 18:36:54 +02:00
Maurice c82a8f899f refactor(roadtrip): one table for the six stop kinds, not nine lists
The kinds were written out in nine places: the enum in @trek/shared, the
service list and the colours in the model, the corridor's category keys, the
map's road-trip categories, the popup's own table, two lookups in the rail, one
in the map markers and one in the corridor panel. Each carried a comment asking
the next person to keep the others in step, and it came apart anyway.

A rest area was a parking sign in the popup, on the map pill and in the panel,
and a coffee cup on the rail disc and the map marker, for the same stop. The
parking sign wins: the colour has been documented since it was written as "the
blue every parking sign in Europe is printed in".

stopKinds.ts holds one row per kind and everything else reads from it. It sits
beside the model rather than inside it because an icon is a React component and
roadtripModel.ts is deliberately React-free so the arithmetic can be tested on
its own; the colours and the service list stay there and are read from here.
Two flags rather than one, because they are not the same question: a hotel is
something the corridor can find, but sleeping somewhere ends the day rather
than interrupting the drive.

Seven tests pin the table against the two lists that cannot import it, so the
next kind added in one place and forgotten in another fails a test instead of
quietly counting as a destination.
2026-09-07 18:36:54 +02:00
Maurice 23bda0e1e1 test(roadtrip): register the via tools in the MCP test registry
The registry the MCP tests build had every other domain and not this one, so
the four via tools were absent from every assertion that walks it. A test that
asked whether they were reachable, or gated, would have passed without them
ever being there.
2026-09-07 18:36:54 +02:00
Maurice d1a13058a6 fix(roadtrip): make the addon gate on the REST routes actually run
@RequireAddon has sat on the controller since the domain was written and did
nothing: it is metadata, AddonGuard was not in the chain, and it is not one of
the three global guards either. The addon ships disabled (seeds sets enabled 0),
so on any instance that never switched it on, all six via routes answered
anyway. Two doc comments in this repo already describe the behaviour this
commit creates rather than the one it replaces.

AddonGuard leads the chain, the way reservation-import and collections do it,
because a disabled addon owes an anonymous caller a 404 rather than a 401.
2026-09-07 18:36:54 +02:00
Maurice 25249749ad test(planner): give the page fixture the road trip state it reads
The page reaches into routeAlternatives.open, roadtripRoutes.lines and
roadtripVias.byDay before it ever asks whether road trip mode is on, so a
fixture without them took all 47 wiring cases down with "Cannot read properties
of undefined". The mode stays off, which is the state every assertion here is
written against.
2026-09-07 18:36:54 +02:00
Maurice 9a0a6f8c3b fix(settings): the routing endpoint belongs to the operator
routing_base_url arrived on this branch and was assigned to neither managed
set, which MANAGED-KEYS-001 exists to catch. It is locked, alongside the other
infrastructure endpoints: the host it names is folded into the CSP once at boot
(routingCspOrigins), so a customer who repoints it lands in a policy that does
not know their router and the browser blocks the requests with nothing to
catch. MANAGED-KEYS-005 pins the list verbatim and is updated with it.
2026-09-07 18:36:54 +02:00
Maurice 7829043a00 test(app-config): pin the overpass budget against its own constant
The client-side budget moved to twenty-five seconds when the query timeout it
has to outlast was written down as a constant, and this assertion stayed on the
old twelve. Pointing it at OVERPASS_TIMEOUT_DEFAULT_MS is what the test was
after in the first place: the budget must be longer than the timeout the query
carries, and a literal here is exactly how the two came apart.
2026-09-07 18:36:54 +02:00
Maurice 7b5c8c1fae test(places): teach the e2e tables the stop_type column
Three e2e suites build their own trimmed places table with a plain CREATE TABLE
rather than going through the schema and the migrations, so the column this
branch added never reached them. Every assignment, day and place case that
touched a place failed on "no such column: stop_type", thirteen in total, and
none of it showed because the checks do not run on a draft.
2026-09-07 18:36:54 +02:00
Maurice eebe9a52e1 fix(places): let a stop type be corrected over MCP, not only set
create_place learned stop_type when the column arrived; the three tools that
could change a place did not. Over MCP the kind was therefore writable exactly
once and never afterwards: a place saved as fuel fell out of every stop count,
wore the wrong disc for good, and could only be put right through the REST API.

update_place and bulk_update_places take it nullable, which is how the service
already reads "turn this back into an ordinary place" (an explicit null rather
than COALESCE). create_and_assign_place takes it the way create_place does.

The description also named four kinds where the enum holds six, so the model
had no way to reach restaurant or sights.
2026-09-07 18:36:54 +02:00
Maurice f1fa155899 fix(roadtrip): gate the via tools on the addon, the way the routes are
The controller carries @RequireAddon(ROADTRIP), the four MCP tools carried
nothing. On an instance that left the addon off, REST answered 404 while
list_route_vias, add_route_via, reanchor_route_vias and remove_route_via kept
reading and writing, which is exactly the parity the repo refuses to break.

Same shape as budget: an addonGate constant, AddonsService held publicly so
the module-level gate can reach it, AddonsModule in the imports.
2026-09-07 18:36:54 +02:00
Maurice 4f57c1ee13 fix(pdf): stop printing a rental's counter twice with an arrow between
The car branch prints the endpoint chain whenever it holds two entries, which
an ordinary rental always does: picked up and returned at the same desk. The
printout then reads "Munich Airport -> Munich Airport", where before there was
no car branch at all and the line simply stayed empty.

Consecutive repeats now collapse and a chain naming one place only is dropped.
A genuine round trip keeps both ends, because Dresden sits between them.
2026-09-07 18:36:54 +02:00
Maurice 3d252ac994 fix(places): keep the hour a new place has always defaulted to
The road trip rail reads `duration_minutes` as a stay, and a place created
without one claimed an hour nobody had entered. The branch answered that by
binding NULL instead, which is right for the rail and wrong for everything
else: the column declares DEFAULT 60, three tests assert it, and the comment
on one of them says so in as many words. That default reaches every place in
TREK, not only the ones on a drive.

So the hour stays, and the road trip solves its own problem where it arises.
A stop added from the corridor search already brings its own figure with it
(RoadtripStopPopup's STOP_KINDS: ten minutes for fuel, twenty for a rest area,
thirty for a charger), and saveStopDraft passes it through, so the kinds that
would be misread as an hour never take the default in the first place.
2026-09-07 18:36:54 +02:00
Maurice e11ae6f056 feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.

Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.

Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.

Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.

How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.

Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.

Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
2026-09-07 18:36:54 +02:00
Maurice 1db55f734c fix(maps): wait longer than the query we ask Overpass to run
The query carries `[timeout:20]`, so the mirror is allowed twenty seconds of
work on it — and the fetch around it was aborted after twelve. A mirror under
load that would have answered at fifteen never got the chance, and the caller
saw a failed area.

That is what makes a corridor search along a long drive report half its
stretches as unsearchable: the stretches were being searched, we just hung up.
Both numbers now come from one constant, the client budget is the query's own
plus room to hand the answer back, and a test holds the relationship rather
than the two literals.
2026-09-07 18:36:54 +02:00
Maurice b339ef3f04 fix(days): keep a place's stop type on both assignment loaders
Three read paths built the same nested place object by hand, and two of them
left `stop_type` out of it while their SQL was already selecting the column.
The effect was visible: a petrol station added along a route drew correctly
for a moment from the optimistic write, then the server's answer replaced it
with a place that had no kind, and the road-trip rail redrew it as an ordinary
numbered destination.

`getAssignmentWithPlace` now goes through `formatAssignmentWithPlace` like the
list path already did, which removes the third copy rather than adding the
missing field to it. The two queries in the days service gain the column, and
its hand-built shape gains the field it cannot yet be spared.
2026-09-07 18:36:54 +02:00
Maurice f1eadd670d test(roadtrip): read the last mock call without Array#at
The client's lib is ES2020, where Array#at does not exist. Local typecheck ran
before the file was added and CI caught it.
2026-09-07 18:36:54 +02:00
Maurice d6955f20a6 test(roadtrip): cover the routing, corridor and panel behaviour
The addon shipped with only its two pure modules under test, which left the parts
that actually talk to the network — and the panel a user reads the answer from —
resting on nothing.

Covers the routing hook (one request per travel-mode run, day default over trip
fallback, retry then give up, no re-fetch on a rename, abort on leave), the
corridor search (one query per box carrying every kind, the perpendicular filter,
dedupe, the retry pass before a stretch counts as unsearched), the corridor state
(roads driven over the line between stops, day follow-along) and both panels.

Two findings from writing them, fixed here:

- A dead straight leg is reduced to its two ends by the simplifier, and tiling
  walks point to point — so a 250 km motorway stretch produced a box at each end
  and nothing in between, while the search still reported itself complete. Steps
  longer than half a box are now split before tiling.
- projectOnSegment guarded a degenerate segment with `lenSq === 0`, which two
  ends that came through a chain of floating point arithmetic never hit.

Also splits the anchor resolution out of computeSchedule, which was three levels
of nesting for one question, and sorts the POI cache key with a comparator.
2026-09-07 18:36:54 +02:00
Maurice 6aa7884874 perf(maps): search a corridor with one query per box, not one per category
The road trip corridor asks for fuel, charging, rest areas and campsites over a
dozen boxes, and asking per category was four times the requests for the same
answer — slow, and four times the chance a shared Overpass mirror times out and
reports the stretch as unsearched.

`category` now takes a comma-separated list. Each OSM selector belongs to exactly
one category, so a hit is still labelled with the kind it answered, and the
result cap is shared out per category so one dense kind can't crowd the rest.
The MCP tool takes an array for the same reason.

Also puts the POI, plugin and route via-point pins on the map's render clock,
the same fix the planned-place pins already had: a library marker repositions on
every pointer sample while the canvas draws once a frame, so the suggestions
swam during a drag and snapped back on release.
2026-09-07 18:36:54 +02:00
Maurice 37179cb608 feat(maps): show a chain's own logo on its map pin
A corridor full of petrol stations was a row of identical coloured dots, and the one
thing that tells them apart is the brand. OSM carries brand:wikidata on most of them —
88% of the fuel stations in a Berlin box — Wikidata carries the logo as property P154,
and Commons serves it.

The bytes are proxied and cached rather than linked: one self-hosted instance asking
Wikimedia for a handful of logos is a very different egress profile from every
visitor's browser announcing which petrol stations they are looking at. The cache is in
memory because a GET must not write to the database, and there are only so many fuel
brands; a brand with no logo is remembered too, or every pan would ask again.

A logo-less or unreachable brand answers 204 and the pin keeps its category icon, so
nothing depends on Wikidata being reachable. Both renderers show it, since both read
the same category table.
2026-09-07 18:36:54 +02:00
Maurice 74258bd5d9 feat(roadtrip): give the corridor hits their own icons, and say when a stretch went unsearched
A petrol station and a campsite both came back as the same nameless grey dot, because
the road categories only existed on the server: the map falls back to grey for a
category it does not know. They now sit in the client's category table too — with the
pump, the bolt, the parking sign and the tent, in their own colours — which both map
renderers and the popup read from the same place.

They stay out of the explore pill's own list on purpose: that pill offers what a city
visit needs, and while road trip mode is on it is not on screen anyway.

The search also stayed quiet when only some boxes failed, which reads as "no fuel on
this stretch" when the truth is that nobody looked. A partial failure now says how many
stretches went unanswered.
2026-09-07 18:36:54 +02:00
Maurice 552bd61d11 fix(roadtrip): search along the roads driven, not the lines between stops
The corridor was built from the day's stops, so it followed straight lines across
country the car never drives. Between two cities that line runs through open fields
while the motorway swings wide of it, which is why every hit came back sitting on a
stop rather than on the way.

The days now carry the routed geometry, and the corridor is cut from that. Same trip,
same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last
stretch — where before all of them reported kilometre zero.

Following real roads takes more boxes to cover the same drive, so they go out three at
a time instead of strictly one after another, and the budget rises from ten to sixteen.
The panel head loses the sentence explaining what the sections below already say, and
the day's distance and driving time, which the day list has been showing all along.
2026-09-07 18:36:54 +02:00
Maurice 647e972c8b feat(roadtrip): draw corridor hits on the map and build the day in one request
Three things the first cut got wrong.

Switching into road trip mode took its time: every leg was its own routing request,
paced a second apart so the shared hosts would answer at all. The router already
returns a leg per consecutive pair of the waypoints it is handed, so a day is now one
request — split only where the travel mode changes, because one request carries one
mode. Answers that come from the cache are no longer paced at all, which is what made
returning to the view feel broken.

A list of petrol stations is half an answer: which one is on your side of the road is
the other half. The corridor search therefore moved out of the panel into the planner,
and its hits are handed to the map like any other place suggestion — grey, because they
are not part of the trip until you add one. The explore pill stands down while the mode
is on: it draws into the same channel and answers the same question for a drive.

The panel itself was a stack of controls without an order. It now reads as header,
search, results: what is being searched (with the day's distance and driving time),
what is being looked for, how far off the route, then the hits grouped by kind with a
count — and a progress bar while the boxes are being worked through.
2026-09-07 18:36:54 +02:00
Maurice 07a4447bbd feat(roadtrip): find fuel, rest areas and sights along the drive
The road trip mode could show the route but not what sits beside it, which is
half of what planning a drive is about (#1797).

The right column becomes a corridor search: pick what you need, pick how far off
the route you will go for it, and the day's drive is searched box by box. The
boxes come from the route itself rather than the viewport, so the answer is
"on the way", not "on screen"; each hit reports how far off the route it lies
and how far into the drive it comes, and adding one opens the normal place form
so it lands on the day like any other stop.

Overpass is asked in plain bounding boxes rather than with a polyline filter,
because that is what the existing /api/maps/pois route accepts and what the
mirrors can answer; the geometry work is pure and tested. Long routes stop at ten
boxes and say so instead of quietly searching half the drive.

Fuel, charging, rest areas and campsites join the server's POI categories, so the
explore pill and the MCP search_pois tool gain them too. A car booking's stops now
also reach the PDF, which printed pick-up and return with everything in between
missing.
2026-09-07 18:36:54 +02:00
Maurice 3902e77991 feat(roadtrip): add the road trip addon with a drive-first plan view
The trip planner could show one day's route at a time and nothing across days,
so a road trip had to be pieced together in Google Maps (#1797, #435).

The addon adds a mode to the plan view rather than a tab of its own: the map
stays the workspace and gains the route over the whole trip, while the left rail
becomes one chain of stops with the driving distance and time between them and
arrival times that follow from them. A time pinned on a stop is kept and restarts
the chain, so a ferry or a museum ticket stays where the user put it.

Car bookings can finally carry the stops the schema has always allowed: the
endpoints table has held role/sequence for every transport type since #384, but
only flights and trains had an editor for them. The MCP tools already accepted
them, so this closes the gap the other way round.

Legs are fetched one at a time and spaced out — the public routing hosts answer a
whole trip's worth of requests with 429 — and the distance cap now looks at the
single leg instead of the sum, which is what dropped long drives entirely.
2026-09-07 18:36:54 +02:00
Maurice 7cdd61e7fb fix(maps): say which source answered, and stop the stubs driving the index
Two halves of one mistake, both found by the suite.

Letting each side of the merged search catch its own failure meant the index
could contribute nothing while the answer still called itself
"trek-places+openstreetmap". That label is not decoration: the search log writes
it into the corpus a candidate index gets scored against later, so a list that is
entirely OpenStreetMap must not be recorded as though the index had a hand in
it. The source now names what actually contributed.

And maps.service.test.ts drove the index for real. Its fetch stubs answer with
json() and no text(), which is not what the index client reads, so the branch was
entered and then failed on the stub — "uses Nominatim when user has no API key"
was really testing that a stub is incomplete. With the new circuit breaker those
failures also accumulated, and the breaker is process state, so it leaked into
whatever file ran next. trekPlacesSearch is stubbed there now, beside the nearby
stub that was already there for the same reason. The index paths keep their own
files.
2026-09-07 14:05:33 +02:00
Maurice ed5769fc89 fix(maps): name the index as the source, and stop it being asked twice
Attribution first, because this branch argues the point itself: the card names
the sources with the comment that naming them is a licence obligation rather
than decoration, and the copy beside it promises that every field says where it
came from. The POI path then stamped Overture rows as OpenStreetMap. Overture is
not OpenStreetMap — it carries OSM among other sources under other licences — so
that is a wrong attribution rather than a rounded one. The wire contract keeps
`source` an open string and nothing on the client switches on it, so saying
`trek-places` costs nothing. The MCP tool description no longer calls itself an
OpenStreetMap tool either, and says which source answered.

Adding one place cost two round trips to the index. The details lookup fetches
the place and its answer carries the description; toPlaceRecord dropped that
field, so the enrichment fetched the same place again a moment later to read it.
It is carried through now and read from what the caller already has, with the
second lookup left as the fallback for when there is nothing to read.

That description's source URL becomes an href on the client and was taken on
`typeof === 'string'` alone. It now goes through the same allow-list a place's
website goes through — the file already does this for menu_url, with the comment
explaining why — so anything but http(s) loses the link and keeps the text.

The instance token was hashed from raw APP_URL, which the shipped compose file
leaves commented out. Every default install therefore sent the same token, and a
token every caller shares is not a caller: one instance running a large import
would rate-limit all the others, who would fall back to Nominatim with nothing
to point at. getAppUrl() falls through to ALLOWED_ORIGINS and then to the port,
so unconfigured instances still differ.

Two client-side leaks. A pick made out of the offline cache was recorded into
the shadow corpus, where it moved the two figures the corpus exists to
measure — and offline is a setting, so the network is usually reachable and the
row landed. And the prefetch never re-checked the session after its request came
back: a logout in that window repoints the handle at the anonymous database, so
one person's trip area was written where the next person's offline search reads.

The cache path also ignored the abort signal, so an answer for a keystroke two
keystrokes old could overwrite a newer suggestion list. It rejects the way both
surfaces already know to swallow.

Left alone, and said in a comment rather than hidden: the index cannot localise.
The Overpass path picks name:<lang> and falls back to int_name; the service has
no language parameter at all, so a German reader exploring Tokyo gets the
Japanese primary names on this path.
2026-09-07 14:05:33 +02:00
Maurice b0aa7f8d95 fix(maps): stop asking an index that is not answering, and untie the place cache
Three things an install feels rather than reads.

A service that cannot be reached cost the full timeout on every call. Each call
site falls back to the old path, but pays 3.5 s first — so an instance behind a
firewall that drops rather than refuses, or one that simply has no route to the
service, pays it on every keystroke of autocomplete and on all three queries a
booking import runs per venue. Ten venues is over a hundred seconds of waiting
for an answer that was never coming. A process-wide breaker now fails instantly
after four consecutive failures and tries again a minute later, so an
unreachable index costs one timeout instead of one per request. A 404 does not
count against it, because "no such place" is an answer, and neither does an
oversized body, because the service replied.

The offline place cache hung off the map-tile switch. That setting is about
tiles — its own text says "off keeps the cache to trip data + documents only" —
and it gets turned off for space, tens of megabytes of it. A trip's places are
about one. Somebody who turned tiles off therefore got an empty offline search
with no explanation, and "prepare for offline" reported success having stored no
place at all. The two run independently now; the tile progress phase stays gated
on the tile setting, so a run without tiles still reports no tile phase.

Tests for the merged search path, which had none: the wiring around
mergeSearchResults was uncovered, so which list goes in first and what `source`
says were both free to change. Neither is cosmetic — index-first is the whole
point of the feature, and the search log writes `source` into the corpus a
candidate index gets scored against later. Two of the eight cases pin the fix
from the previous commit: an index failure keeps the OpenStreetMap answer, and
neither source is asked twice.

place-shadow lands on its own coverage entry rather than the 80 catch-all, where
a new domain could shed twenty points unnoticed. Pinned under its measured
figure on purpose; the exact ratchet gets regenerated with the repo's own script
after a full run.
2026-09-07 14:05:33 +02:00
Maurice 48f0f29273 feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI
pills, booking-import geocoding, the offline area and details resolution — and
written by nothing. No admin route, no MCP tool, no switch anywhere in the UI,
and the key is not in the settings allow-lists either, so the only way to set it
was an INSERT against app_settings by hand on the server.

That matters more than the four switches beside it, which trade away quota. This
one decides whether every search and every keystroke in the place box leaves the
instance at all. The flag reads fail-open, so an install that upgrades without
visiting the admin panel starts sending them, which is the right default and
exactly why the operator needs somewhere to say no.

Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces
/updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit
row, and a real switch on the card. The read stays fail-open so it keeps
agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement
across every stored value — if those two ever diverge the panel shows "off"
while the queries keep leaving, which is worse than either default.

The card said this itself: `available` was a display-only prop whose comment
read "False until the client is wired to the service; the card then says so
instead of offering a switch that would do nothing." It is wired now, so the
prop is a switch. Its two badge strings had been shipped to all 23 locales and
rendered nowhere; they make way for the switch label and a line naming what
answers while the index is off.
2026-09-07 14:05:33 +02:00
Maurice 5aa36aaf3d fix(maps): stop the index costing a second search, a duplicate and a lost cache
Six things the merged search and the offline cache got wrong.

An index failure took the OpenStreetMap answer down with it. Only the OSM half
of the Promise.all had a catch, so a rejection on the index side discarded ten
results that had already arrived and sent the same query to Nominatim a second
time, behind its 1.1 s process-wide throttle. The index refusing a query is
ordinary traffic rather than an outage — a common single word without
coordinates is turned down upstream as too expensive — so this fired in normal
use. Each side now catches its own failure, and the keyless fallback reuses the
answer OpenStreetMap already gave instead of asking again for a list that
cannot have changed.

Dedup never fired for a non-Latin name. namesOverlap splits on [^a-z0-9], so
長谷寺, Кремль and مسجد الشيخ زايد all tokenise to nothing and the function
returned false — the same place came back once from each source, one row under
the other, and picking either gave it a different id. That is the corpus this
whole feature was measured on. Two names with no Latin words are now compared
strictly, folded through NFKC so the width variants the sources disagree on
still match; sharing a character is deliberately not enough, because deduping
東京タワー against 東京タワー駅前郵便局 would swallow a real result.

expand=1 answered nothing for an index place while expand=0 answered in full.
The expanded lookup degrades OSM ids to the plain one and had no branch for the
new family, so the richer request was the poorer one, over REST and MCP alike.

The bias circle could exceed Google's 50 km ceiling. A trip spread over more
than that produced a radius Google rejects outright, turning a search into an
error toast. Clamped on both sides — the client because it draws the box, the
server because the radius arrives over the wire.

The offline cache was rebuilt on every sync and never deleted. syncTrip wrote
its meta row with put(), which replaced the whole record and dropped the
fingerprint saying the cached area was current, so every login, reconnect and
manual sync re-downloaded megabytes that had not changed. And clearTripData
never touched areaPlaces, so a trip switched off for offline use kept answering
offline searches and its rows stayed for the life of the install.
clearCachedPlaces, which claimed to do that job, was called from nowhere; its
only test asserted with .every() on an empty table and passed on nothing.

Picking an offline suggestion ended in an error toast. The details lookup had
no cache path, so it failed and the callers fell back to searching for
"name, address" — which the cache matches on the folded name alone, so it found
nothing for a place that was sitting right there. A miss stays a miss.
2026-09-07 14:05:33 +02:00
Maurice 273bac8917 fix(maps): keep index ids away from Google, and let the area download be big
Four things the index changed and nothing followed:

`gers:` was missing from NON_GOOGLE_PLACE_ID, so isGooglePlaceId said true
for every id the index hands out. Five call sites gate on that function to
keep unresolvable ids away from Google, which answers them with a billable
400 INVALID_ARGUMENT: photo refs, the editorial summary, the place-photo
route, the expanded details lookup and the enrichment photo pass. Since the
index answers first for search and autocomplete, a GERS id is what an
ordinary new place now carries, so this was the normal case rather than an
edge one — three billed lookups per added place, none of which could ever
return anything.

The area download shared the search path's megabyte cap and its 3.5 s
timeout. Both are right for a keystroke and wrong for the offline prefetch,
which asks for 3000 rows at once: a city-sized answer sits on that megabyte,
readCapped threw, and the caller could only report "index unavailable" — the
trip then went offline with an empty place cache and nothing said so. The cap
now follows the row count the caller asked for, with a floor, and the bulk
call gets a budget that fits a bulk download. It is still capped, so a broken
upstream cannot hand us an endless body.

searchPlaces returned before the Google branch as soon as the index or
OpenStreetMap had anything, and both set google_place_id to null. That is
right for a search and wrong for the one caller that wants an identity rather
than an answer: list-import enrichment (#886) exists to attach a
google_place_id to a bare imported pin, and pickEnrichmentMatch discards every
candidate without one. So the import silently stayed unenriched on an instance
that pays for a key. That caller now asks for the Google identity explicitly.
Nothing changes for an instance without a key — enrichment could never resolve
anything there, before or after.

Tests cover the three functions this branch had left untested, which is what
put the maps ratchet under its functions threshold: the autocomplete index
path (a new file, MAPS-AUTO-001..007), the gers details branch when Overpass
is down (MAPS-GERS-008), and the client's abort timeout — including that the
signal exists at all, which nothing asserted before.
2026-09-07 14:05:33 +02:00
Maurice 2b7af6267b test(server): cover what this branch added to the pinned domains
The coverage ratchet measures per domain, and this branch put new code into
four domains pinned well above the floor without tests reaching it.

addons (99/99/99): getPlaceShadow and updatePlaceShadow had none at all, and
the parity the service comment claims with PlaceShadowService.enabled() is now
asserted rather than asserted in prose.

maps (93/86/97/96): geocodeQuery was invisible - its only consumer stubs it
away - so the new suite drives the gate, an accepted hit, a hit with an
unusable coordinate, an index failure falling through, and both ends of the
Nominatim tail. It sits on the booking-import path, where it decides whether
thirty sequential lookups leave the instance.

admin (86/70/89/88) and place-enrichment (96/95/94/97): the place-shadow
handlers and their audit entry, the getAppConfig read, and nearbyWouldMislead,
whose lines ran incidentally while nothing asserted the result - the guard
could have been inverted and the suite would have stayed green.
2026-09-07 14:05:33 +02:00
Maurice d613c91e16 fix(places): keep the chain out of the field that means "this place"
toPlaceRecord wrote the brand's Wikidata item into `wikidata`, and
getPlaceDetails spreads the record over the OSM details, so for a chain branch
picked from the index that value replaced the building's own tag - and nulled a
real one when the place had no brand. resolveIdentity reads that field as the
place's own identity, so the photo ladder returned the chain's Commons files as
pictures of this branch and the description came back as the company's article
without the aboutBrand flag.

That is precisely the failure WIKI_IDENTITY_TAGS and readBrandIdentity were
written to prevent, in their own words: "a chain's logo passed off as a photo of
the restaurant would be a plain lie". The item now travels under brand:wikidata,
the key OSM uses and readBrandIdentity already reads, and resolveIdentity picks
it up from the payload for a place OSM does not know - so a branch still gets
the chain's description, flagged as such, and never its logo.

The client test also covers trekPlacesArea, the streaming half of readCapped
including the size cap, the configured base URL and the nearby defaults, none
of which any test reached.
2026-09-07 14:05:33 +02:00
Maurice 0991fa538b fix(maps): stop pois() dropping opening hours and reporting a narrowing as none
The index-first branch hardcoded opening_hours: null while the payload it maps
carries them, and search_pois advertises "opening hours wherever OSM carries
them". It also capped the radius at 20km and then answered clamped: false, so a
wide viewport came back narrowed and said it was not - the same field the
Overpass path fills honestly.

The three red MCP cases were the same code telling the truth from the other
side: they stub Overpass one level below the facade, so pois() ran for real,
and because trekPlacesEnabled() fails open on a database with no row, a unit
test was sending live requests to places.liketrek.com from CI. That is what
answered with 50 places instead of the stubbed one. The index client is now
mocked in that suite, with the gate left open so the cases still run on the
shipping configuration, and the index-first path gets cases of its own here and
in a new maps.pois suite: field mapping, the empty and the failing index both
dropping through to Overpass, and an outage under both providers still reaching
the caller as an error rather than an empty list.

maps.service.test.ts drove pois() with no stub at all and leaked the same way.
2026-09-07 14:05:33 +02:00
Maurice 65eebec53f fix(mobile): hint the suggestion fallback the same way the search is hinted
When details fails to resolve a picked suggestion the sheet falls back to a
text search (#1192). That call was the one search left in the sheet without a
location hint, so the fallback could land on an identically named place in
another country - exactly what the hint exists to prevent, on the path taken
when the first hop already failed.
2026-09-07 14:05:33 +02:00
Maurice 15c1a5ae06 test(mobile): seed the store the location hint is actually read from
useLocationBias reads useTripStore; the sheet's harness only ever built a
planner prop. So the hook saw an empty store, sent no hint, and the one case
that asserts a box went red while its three siblings passed vacuously - they
expect undefined, which an empty store produces no matter what they seed.

capturedBias now seeds places, assignments and selectedDayId the way the
running app has them, which puts all four cases back to work. The 500km case
is retargeted from Tokyo/Sapporo to Tokyo/Nagoya: 264km across, biased under
the old diagonal rule and dropped under the 60km radius one, so it guards the
rule that actually shipped. A new 029b pins the day-first precedence - a trip
too wide to bias still hints at the day being planned.
2026-09-07 14:05:33 +02:00
Maurice 4c52d16b7e test(admin): follow the API keys card to the theme tokens
The three AdminPage cases located the card with closest('.bg-white'), which
returned null once the card became bg-surface-card, and within(null) reports
that a line later as "container is null". Anchor on .rounded-xl, the same way
the green AdminSettingsTab suite already does, and ask for the single Test
button rather than the first of several: weather left the card when it stopped
needing a key.

FE-ADMSET-031 asserted the Open-Meteo panel inside that card. The panel was
deliberately removed from the desktop tab - it needs no key and had nothing to
configure - so the case now pins what took its place, the TREK index heading
the card with the recommendation the Google key used to carry. The panel itself
still lives in the mobile section, where FE-MOB-ASET-019 asserts it.
2026-09-07 14:05:33 +02:00
Maurice c0eb1c6e38 fix(maps): send the location bias the search route already accepted
MapsSearchDto validates a locationBias and MapsService passes it through, but
no caller ever sent one. That gap is the whole difference between the numbers
in this PR and what the software does. Measured against a live instance over
the 127 places of a real trip, searching each by name:

  without a hint   50.0% -> 54.8% top-5
  with a hint      50.0% -> 73.0% top-5   (this PR reports 72.7%)

mess/eval_merge.mjs sends lat/lng on every index call and a viewbox to
Nominatim; the client sent neither. Without coordinates the index refuses
common queries as too expensive and contributes to 41% of searches instead of
100%, so the merge degenerates to Nominatim alone.

The hint has to be NARROW, which is the part that is easy to get wrong. Same
corpus, same instance, varying only how the box is derived:

  no hint                            54.8% top-5, 38.1% at rank 1
  box around the whole trip          57.1% top-5, 17.5% at rank 1
  box around the nearest few places  70.6% top-5, 34.9% at rank 1

A box spanning a round trip has its centre in open country between the cities.
It points at nothing and costs more than half the rank-1 hits. useLocationBias
therefore prefers the day being planned, falls back to the trip only while it
still fits inside one region, and drops the hint past a 60km radius rather
than sending a misleading one.

This also replaces the two hand-rolled copies of the bounding box in the
planner dialog and the phone sheet, which shared a defect: Number(null) is 0,
so a place without coordinates entered the box as the Gulf of Guinea and
stretched it across the planet.
2026-09-07 14:05:33 +02:00
Maurice fb363f22c8 fix(maps): alternate between the index and OpenStreetMap instead of ranking them
Measured on 128 places out of a real trip to Japan, all saved through
the old search. Index alone: 51.6 percent inside the top five. Index and
OpenStreetMap alternating: 72.7 percent.

Two cleverer orderings were tried first and both lost. A score threshold
cannot separate the cases at all: at 0.75 it catches 19 of 62 misses and
throws away 6 of 66 hits. Promoting results whose name matches what was
typed is worse than it sounds, because "Hase Station" shares a word with
"Hase-dera" while OpenStreetMap answers in the local language and
returns the right temple under a name that shares nothing: that rule put
seven weak index matches ahead of the correct answer, which landed at
position eight. Alternating puts it at position two.

So the merge does not rank. Each source keeps its own ordering and they
take turns, index first. An exact business match still opens the list;
a landmark Overture does not carry is second rather than eleventh.
2026-09-07 14:05:33 +02:00
Maurice 3caafe0548 fix(maps): show OpenStreetMap alongside the index, not instead of it
Measured against 128 places out of a real trip to Japan, all saved
through the old search: the index answered 51.6 percent inside its top
five. For most of the rest it returned the shops AROUND the landmark
rather than nothing, which is worse than nothing, because ten plausible
wrong answers look like an answer.

Overture Places is a dataset of businesses and is very good at those. It
largely does not carry temples, bridges, riverside walks, viewpoints or
observation decks, which is a fair share of what somebody planning a
trip searches for.

Two ways to detect "the index does not have this one" were measured and
both failed. Its own score does not separate the cases: a 0.75 threshold
catches 19 of 62 misses and throws away 6 of 66 hits. Name overlap does
not either: 57 false positives, because "Hase Station" shares a word
with "Hase-dera".

So this does not classify. Explicit search asks both concurrently and
merges, name matches first and the index ahead of OpenStreetMap within
each group. Autocomplete is untouched, because Nominatim's policy names
it as unacceptable use. Nominatim also gets the location bias it never
had, without which "Hase-dera" returns the temple in Nara rather than
the one in Kamakura the user is standing beside.

Two booking-import cases still stubbed searchNominatim after geocoding
moved to geocodeQuery, and stubbed it with the old array shape, which
made "has coordinates" pass on an undefined lat.
2026-09-07 14:05:33 +02:00
Maurice bac85cb98a feat(places): opening hours from the index, with OSM still first
The index can now carry opening hours, from two sources: an
OpenStreetMap layer the service keeps as a separate database file, and
the schema.org data a place publishes on its own site, read in the same
page fetch the description comes from.

OSM stays the first choice where it has them. Measured across seven
countries it covers 27.5 percent of gastronomy, and an OSM entry
describes this exact object and gets corrected by people who walked past
it, where a chain's website often carries one set of hours for every
branch. The rest is what the operator publishes, which measured 8.3
percent on the same kind of place.

Both go through the same expansion into weekday lines, so a card reading
`opening_hours` gets one shape and not two.

Five maps.service cases went red when search started answering from the
index ahead of Google; they are about the shape of the Google request,
so they now switch the index off first.
2026-09-07 14:05:33 +02:00
Maurice 87a765b763 feat(offline): cache the trip's area so place search works with no network
An offline instance had the trip's own places and nothing else, so "find
a pharmacy near the hotel" had nothing to answer from: every lookup went
to a server.

The index can hand over a bounding box in one request, so it does, once,
next to the map tiles. A trip to Rostock is roughly a megabyte, refreshed
only when the trip's area actually moves. Search and autocomplete fall
back to it at the API layer, which is why all ten call sites got the
offline path without any of them learning about it, and the answer says
`source: 'offline-cache'` rather than pretending to be live.

Country packages stay what they were: a SQLite file for anyone who would
rather run their own copy of the service. A browser cannot query one
without shipping a WASM SQLite, and Germany alone would not fit in
IndexedDB, so that door stays where it belongs. /v1/packages lists them,
because "you can leave" only means something if the door is visible.
2026-09-07 14:05:33 +02:00
Maurice 715993feaf feat(places): descriptions from the place's own website
Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.

The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.

In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
2026-09-07 14:05:33 +02:00
Maurice b3d69aa760 feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.

Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.

Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.

Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.

Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
2026-09-07 14:05:33 +02:00
Maurice 0a2fa32fc1 feat(admin): rebuild the API keys card around the TREK index
The card was a flat list: two key fields, four Google switches and a large
weather panel, all at the same level. That gave a switch you set once the
same weight as the key it depends on, and gave the loudest treatment on the
page to the one thing with nothing to configure.

Now three comparable provider blocks. The TREK index carries the accent and
the recommendation, because a key should be the exception rather than the
expected setup. Google carries no border badge, so it does not stand out,
and says plainly what it costs beyond money. Weather has left the card: it
needs no key and had nothing to set.

The "recommended" badge moved from Google to the index, which is the whole
point of the change.

Also here, and both are their own thing:

- The settings tab is two columns from xl up. Nearly every card is "label
  left, switch right", so the middle stayed empty while the page scrolled
  for ages. Explicit columns rather than a grid over the flat list, because
  a grid pairs cards row by row and leaves a hole under the shorter one,
  and these differ by hundreds of pixels.
- Pictures: the nearby fallback drops from 300m to 60m and is skipped
  entirely for shops and gastronomy. Measured across 600 places in six
  cities, 2 percent of ordinary businesses have a picture on Wikimedia
  against 70 percent of churches, so for a cafe the curated rungs never
  fire and the fallback always does. That is how the town hall ended up
  over the doner shop. It fails open on an unknown category, so landmarks
  keep the fallback that works for them.

vite.config reads the dev port and the API target from the environment,
defaulting to what they were. Several worktrees could not run side by side.
2026-09-07 14:05:33 +02:00
Maurice 7f2a63e7f7 feat(maps): search places through the TREK Places API
Adds a provider for the project's own place index and puts it in front of
the existing ones for search, autocomplete, details and the explore pills.
It needs no key, has no quota, and what it returns may be stored, which is
true of neither of the two providers it sits in front of.

Nothing about the Google path changes. Every call site falls through to
exactly what it did before when the index has no answer or the service is
unreachable, so a working search keeps working.

Two details worth knowing while reading:

- Details for a place from the index also resolve its OpenStreetMap record
  by name and coordinate, because the index has no opening hours yet and
  never has cuisine or a menu link. Without that the hours a user saw while
  adding a place vanished from its card afterwards.
- The explore pills keep reporting source 'openstreetmap'. It is a typed
  contract the client switches on, and the places are the same places.

TREK_PLACES_URL points at a different instance; unset means the public one.
2026-09-07 14:05:33 +02:00
Konstantinos Thermos 2a0ceb2687 feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two
Osaka subway stops finds nothing — the stops are not in the feed at all.
Add Google as an optional backend behind an admin setting, reusing the
install's existing Places key, and fall back to Transitous whenever no
key resolves.

Uses the Routes API rather than the legacy Directions endpoint the
request named: every other Google call here already speaks the
v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud
projects created after March 2025.

Google returns walking as turn-by-turn navigation steps where MOTIS
returns one leg per walking segment, so consecutive walk steps are
coalesced into a single leg — otherwise a finely-sliced walk pushed a
real journey past the 20-leg cap and the whole itinerary was dropped.

Both responses now name the backend that answered. The fallback is
silent by design, which left an empty result ambiguous between "no data
here" and "the provider you picked never ran", and the admin panel warns
when Google is selected but no key resolves — or when only the admin's
own key is set, which serves them and nobody else.

Cost shaped the implementation, since Google bills where Transitous does
not: both field masks are cut to the cheapest SKU tier that still
answers the question, cache TTLs are longer than the Transitous path's,
and route alternatives ride along inside the already-billed request.

Addresses #1699
2026-09-07 09:01:56 +02:00
Maurice 8e2840b29d docs(helm): document the probes block, and survive probes being nulled
A probe map is exactly the value someone reaches for when their cluster is
unhappy, so the two ways of reaching for it should not break the render.
`probes: null` hit a nil pointer on `.Values.probes.liveness`; guarding with
`with (default dict ...)` also makes dropping a single probe work, which is what
you want on a pod whose liveness check is the thing killing it.

The rest is documentation the chart already gives every other value: a wiki
section next to Resource Limits, a Notes bullet, and a values comment naming the
one trap here, that a handler switch has to clear the shipped httpGet in the same
override or the API refuses the pod.

Verified with helm template: defaults render byte for byte what the chart
produced before, probes=null renders no probes, probes.liveness=null leaves one,
and helm lint passes.
2026-09-06 17:26:51 +02:00
Owen Holloway 0454a383b3 feat: make health probes configurable in Helm chart 2026-09-06 17:26:51 +02:00
dependabot[bot] 18684f20f3 build(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:12 +02:00
github-actions[bot] 8913782694 chore: bump version to 4.2.0 [skip ci] 2026-09-06 17:10:12 +02:00
dependabot[bot] 52315234fd build(deps-dev): bump browserslist from 4.28.2 to 4.28.8
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:07 +02:00
Maurice 2572132117 fix(notices): let the 4.0.0 release modal cover the whole 4.x line
The version window already said 4.0.0 to 5.0.0, and the test above it is even
called "covers the whole 4.x line", but that was only half the story: without
`recurring: 'per-version'` a notice falls back to a one-time dismissal, so
everyone who closed the modal on 4.0.0 never saw it again. It did not come back
on 4.1, 4.2 or 4.2.1, which is the opposite of what the window says.

The mechanism was already there and in use one entry below, on thank-you-support.
The dismissal path records the app version it was closed at, so the notice
re-surfaces on the next upgrade and stays down within the same version. Rows
dismissed before that column existed read as 0.0.0 and simply come back once.
2026-09-06 16:50:47 +02:00
github-actions[bot] 515398f8ee chore: bump version to 4.2.1 [skip ci] 2026-09-06 14:13:46 +00:00
Maurice e4f2eaf336 fix(trips): stop the two date fields overlapping in the trip dialog
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.

Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.

Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
2026-09-06 16:11:52 +02:00
Maurice 16fee0800d fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the
admin controller helper still built the controller with eight arguments.
2026-09-06 16:11:52 +02:00
Maurice 888cd4ee08 fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
2026-09-06 16:11:52 +02:00
Maurice 8fe90d1783 fix(test): the card is labelled Total trip spend 2026-09-06 16:11:52 +02:00
Maurice 4cda3c4b2e test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 16:11:52 +02:00
Maurice bc85ec1eea fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 16:11:52 +02:00
Maurice 04c22b34af fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 16:11:52 +02:00
Maurice 56a23cb809 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 16:11:52 +02:00
Maurice 251378be1d fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 16:11:52 +02:00
Maurice 973ce5f8ff fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 16:11:52 +02:00
Maurice bbc32310e9 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 16:11:52 +02:00
xvilo ad6e51c574 fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 16:11:52 +02:00
xvilo 6da11994d9 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 16:11:52 +02:00
Maurice eef1f16836 test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-06 16:11:52 +02:00
Maurice 4a8d77c59c fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-06 16:11:52 +02:00
Maurice a70c2eb9e0 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-06 16:11:52 +02:00
Maurice 99b040f684 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-06 16:11:52 +02:00
Xheldon 944e0a0970 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-06 16:11:52 +02:00
Maurice b9f69b0c76 test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-06 16:11:52 +02:00
Maurice fcefc7e771 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-06 16:11:52 +02:00
Mohamed Elkmeshi 6f0a171c74 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-06 16:11:52 +02:00
Maurice 3e1c06a0ee fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-06 16:11:52 +02:00
Xheldon 1aafab83a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-06 16:11:52 +02:00
Maurice c1d0435480 fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-06 16:11:52 +02:00
Maurice 798859517f docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-06 16:11:52 +02:00
RemEx d3fdf78db6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-06 16:11:52 +02:00
Maurice 1b71b8633d fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-06 16:11:52 +02:00
Maurice d77f1c1758 fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-06 16:11:52 +02:00
Maurice e09c0688ae fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-06 16:11:52 +02:00
Maurice 0e66ae72ca fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-06 16:11:52 +02:00
jubnl 61bef334d5 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-06 16:11:52 +02:00
Maurice 755e9f1ca4 fix(trips): stop the two date fields overlapping in the trip dialog
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.

Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.

Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
2026-09-06 15:39:30 +02:00
Maurice b132adf3e3 fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the
admin controller helper still built the controller with eight arguments.
2026-09-06 15:03:13 +02:00
Maurice 94914295d1 fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
2026-09-06 15:03:13 +02:00
Maurice 85f5247a00 fix(test): the card is labelled Total trip spend 2026-09-06 15:03:08 +02:00
Maurice 8a48131618 test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 15:03:08 +02:00
Maurice 0a35ce83a5 fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 15:03:08 +02:00
Maurice eaace6ada4 fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 15:02:58 +02:00
Maurice e6b4df5213 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 15:02:58 +02:00
Maurice 2d315df8bf fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 15:02:58 +02:00
Maurice 04b31ec2e5 fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 13:35:30 +02:00
Maurice 8eda1475c3 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 13:25:43 +02:00
xvilo ea0589b21e fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 13:25:43 +02:00
xvilo 523443f1d7 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 12:56:04 +02:00
Maurice 8c538db74d test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-05 13:22:56 +02:00
Maurice 11a0780a5b fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-05 13:22:56 +02:00
Maurice f75e9e9af8 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-05 13:19:01 +02:00
Maurice 3e532aab99 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-05 12:58:34 +02:00
Xheldon 6953ede0e3 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-05 12:58:34 +02:00
Maurice f81aaa9f4b test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-04 20:29:39 +02:00
Maurice 981cf0a143 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-04 19:53:33 +02:00
Mohamed Elkmeshi dc7d1f5e10 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-04 19:53:33 +02:00
Maurice 6f057ab9f4 fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-04 19:08:15 +02:00
Xheldon ce58de29a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-04 19:08:15 +02:00
Maurice c5208967ac fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-04 18:56:22 +02:00
Maurice 53c246e8e5 docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-04 18:56:22 +02:00
RemEx dba4434ef6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-04 18:56:22 +02:00
Maurice 55f14c6a70 fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-04 14:32:44 +02:00
Maurice 6d54ac629e fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-04 12:15:53 +02:00
Maurice bafcd456c5 fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-04 12:15:38 +02:00
Maurice 9ed88adf34 fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-04 12:15:38 +02:00
jubnl bc10fba389 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-04 00:43:06 +02:00
jubnl fa406b708f plugin-sdk-v1.7.0 2026-09-03 17:30:31 +02:00
github-actions[bot] 09ce5cb733 chore: bump version to 4.2.0 [skip ci] 2026-09-03 14:24:43 +00:00
Maurice 74c47a6158 docs(wiki): add the feature-requests banner used by the release notes
Release notes link the banner from the repo rather than from a comment
attachment, so it keeps working when the notes are edited later.
2026-09-03 16:18:21 +02:00
Maurice 8b291a9a7e fix(maps): handing a place to a maps app leaves no blank window behind (#2218)
In an installed app, opening a maps target in a new context left an empty
window: the platform switches to the maps application before that context
paints anything, and coming back lands on a blank page the user has to
dismiss before TREK is usable again. The handover now happens from the page
the user is already on when TREK runs as an installed app, which is where
there is no tab strip to close a stray window from. A browser tab still opens
a tab, because there a link should.
2026-09-03 15:51:13 +02:00
Maurice 07e4a558e6 fix(bookings): the phone keeps a booking's stop and shows the files it already has (#2216, #2217)
Editing a booking on the phone erased the stop it was linked to. The sheet
seeded its assignment from the create-for-a-stop flag, which is null on an
edit, and saved that straight back over a link the user had made on the
desktop; the server writes the field whenever the key is present. It now
seeds from the booking being edited, and the picker the desktop dialog has
always had sits in the sheet too, so a stop can be seen, changed and cleared
from a phone. The option list moved to its own module rather than being
copied.

Two more of the same shape, found on the way. The endpoint set was sent as an
empty list on every save, and the server swaps the whole set when that key is
present, so editing a transit booking dropped its stations; neither form
edits endpoints, so an edit no longer sends the key at all. And the sheet's
Files block listed only what was picked in this session, so a file uploaded
from the desktop was invisible on the phone. It lists what is attached now,
resolved the way the desktop resolves it.

A place also shows the files of the bookings that sit on it, on the phone and
on the desktop: a confirmation lives on the booking, and the place card was
the one screen that could not reach it.
2026-09-03 15:51:13 +02:00
Maurice 9228ef2bce fix(notifications): a real send keeps its ten minutes, only the admin test is impatient (#2196)
The #2196 timeouts were applied to one shared transport, so the 30-second
inactivity bound landed on the password-reset and notification mails as well
as on the admin's test send. That bound starts counting after the greeting,
which means it bounds the transfer: a relay that scans the message can sit on
DATA for minutes, and a reset mail dropped for that reason fails where nobody
can see it. Nodemailer's ten minutes are back for a real send, the connect and
greeting cuts stay, and the test send keeps the 30 seconds it needs to answer
inside the client's 40-second budget. The source comment already described this
split; only the value disagreed.
2026-09-03 13:59:39 +02:00
Maurice 15f22058e9 refactor(studio): split the content panel and cover the new paths
Three things Sonar was right about. The content panel had grown a filter, a
menu and an upload into one function of thirty-two branches: the filtering
is four pure rules in photoFilter.ts, the row that drives it is its own
component, and what is left in the panel is the grid and the drop zone. The
gallery rows behind an entry upload move out of the store's setter into a
named function, since five levels of nesting is a place bugs hide. And the
ids for placed elements come from getRandomValues rather than Math.random,
in one module instead of four copies of the same line: two editors minting
the same id for two elements is one of them losing their work.

The new paths carry tests now, which is what the gate was really asking
for: the filter's rules, the ids, the upload helper, the store's derived
gallery rows, the canvas file drop, and the shell's upload, drop and stop
switch.
2026-09-03 10:47:08 +02:00
Maurice b383230928 fix(studio): fold the stops list and put a picture into the gallery it lands in
The stops list was the longest thing in the travel panel by a distance: a
fortnight is fourteen rows, and it pushed the maps and the country tiles
below the fold on every journey. It is a folding section now, closed to
begin with, with the count of what still counts in its head, so the closed
section answers the question anyone opens it for. The rows are one line
each, the day loses its weekday, the country appears only on a journey with
more than one, and the switch is a mark rather than a word, which is what
lets the place name fit.

A picture uploaded onto an entry also goes into the gallery, because that is
where the row lives. The entry upload only answers with the entry's view of
it, so the store derives the gallery row it did not get and the content
browser shows a new picture without the journey being fetched again.
2026-09-03 10:47:08 +02:00
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00
dependabot[bot] 23401fcdb9 build(deps): bump fast-uri from 3.1.5 to 3.1.7
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
(cherry picked from commit 56f7e1871f97a2059a47f8b57adb785568d3c861)
2026-09-03 10:07:27 +02:00
Maurice a2cb6a12d1 fix(mobile): a stay chip in the timeline header opens the stay, not the day (#2210)
The hotel chips next to the day pill shared its target, so tapping a stay
opened the day sheet. A chip now carries its stay and opens it the way the
stay card in the day sheet does: the accommodation editor for members who
may edit days, otherwise the hotel's place, and the day sheet only when
neither applies. Closing the editor returns to the timeline it was opened
from instead of surfacing the day sheet, and the chip's accessible name
says whether it is a check-in, a check-out or an ongoing stay.
2026-09-03 09:15:27 +02:00
Maurice ffb6546e51 fix(mcp): prompts take the trip id as the string every client sends (#2207)
Prompt arguments cross the wire as strings, but the three trip prompts
declared tripId as z.number(), so every prompts/get failed with -32602
"expected number, received string", MCP Inspector included. The id is now
parsed from the string through a shared tripIdPromptArg (still an integer
in the safe range), PromptOptions only admits string-input schemas so the
mistake cannot come back unnoticed, and a prompt declared with an empty
argsSchema no longer demands an empty arguments object from the client.
The prompt tests run through a real in-memory MCP client instead of
calling the callbacks directly.
2026-09-03 09:15:14 +02:00
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00
Maurice b1ce7c303d feat(todo): sort the task list by due date (#2205)
The list held its manual order, so an item due tomorrow sat behind one created earlier but due later. The sort-by section gains a due-date toggle next to the priority one, on desktop and in the phone tab: nearest deadline first, undated tasks after all dated ones, ties keep the manual order. One sort at a time, and manual drag-reorder pauses while either is active, exactly like the priority sort.
2026-09-02 21:39:01 +02:00
Maurice c5d10dfd6f fix(client): settle the two findings Sonar raised on the audit branch
The farthest-stop reduce gets its explicit seed (the guard above already refuses an empty list, the seed makes that local), and the toast pill moves into its own component so the host stays below the complexity ceiling.
2026-09-02 21:06:51 +02:00
Maurice ed3216f65d fix(planner): an untimed hotel bookend only closes a local day-trip loop (#2157)
Without stay times the bookend leg drew unless a carrier or a 2000 km edge stop disproved it, which is exactly the reported car trip: home five hundred kilometres away, routed back to the hotel after check-out. The disproof threshold is a day-trip distance now, and the route optimizer anchors through the same rule on desktop and mobile instead of unconditionally, so drawing and optimizing agree.
2026-09-02 21:06:51 +02:00
Maurice 31fa8908fc fix(weather): one failed fetch no longer parks the badge on the dash (#2167)
failed was set once and never cleared, so a single hiccup kept the badge on its error dash while later fetches quietly delivered data. It resets with every new anchor.
2026-09-02 21:06:51 +02:00
Maurice 1428637694 fix(mobile): the filter pill hugs its chips and the list switcher clears the dock (#2104)
On wider phones the dashboard filter pill stretched across the leftover width instead of wrapping its chips. The collections list switcher could not scroll, so its lower entries and the new-list button hid behind the dock.
2026-09-02 21:06:51 +02:00
Maurice 3f49ef8d21 fix(plugins): readable save button, honest respawn, scrolling hosts dialog (#2170, #2159)
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones e70d7397 fixed, and the discover cards get the press-scale hardening from #2158, on desktop and on the phone panel.
2026-09-02 21:06:51 +02:00
Maurice 348d584a02 fix(collab): sanitized markdown keeps angle-bracket text and working footnotes (#2177)
rehype-sanitize silently dropped anything that parsed as an unknown tag, eating text people had saved long before the sanitizer arrived; disallowed markup renders as literal text now. GFM footnotes pointed at a doubly prefixed anchor and never landed. One shared plugin set drives the polls, the notes, the note card and both mobile tabs.
2026-09-02 21:06:51 +02:00
Maurice 4cd25d3047 fix(admin): the addons page re-reads after a journey toggle instead of trusting itself (#2156)
Disabling Journey cascades the photo providers off on the server, but the panel kept its local rows, so after off and on the providers showed active while being disabled. The desktop and the mobile panel re-read the list after a journey toggle; the write itself still rolls back a single row on failure.
2026-09-02 21:06:51 +02:00
Maurice 293dc9e1f1 fix(ui): defuse the press-scale click trap on three more composite buttons (#2158)
Same trap the task-list fix hardened: the press animation shrinks the container mid-click and the release lands next to the target. The collections rows, the journey gallery tiles and the shared vacay calendar cards carry the established no-press attribute now.
2026-09-02 21:06:51 +02:00
Maurice c026c5b1c9 fix(auth): passkey_configured stops advertising the phantom localhost config (#2147)
Without any configuration getAppUrl() invents http://localhost as the relying party, and the UI advertised passkeys that every click answered with not configured. A localhost RP only counts as configured now when the operator declared one. The origin check also binds to the expected origins again instead of accepting every subdomain of the RP id; that widening never shipped in a release.
2026-09-02 21:06:51 +02:00
Maurice a5d87d295a fix(settings): the offline language mirror does not outlive the account (#2146)
The mirror of the account language stayed in localStorage past logout, so the next account on the device booted in the previous user's language forever. It is cleared on logout and rewritten on login, and the rate-limit message follows the same order the boot language does.
2026-09-02 21:06:51 +02:00
Maurice 7c857173cf fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
2026-09-02 21:06:51 +02:00
Maurice c907e0d5e5 fix(packing): bag totals survive reconnects and MCP bag deletes (#2191)
Deleting a bag over MCP moves every figure, the items fall into the unassigned pile, yet it sent no totals ping; it pings the room now like REST and the plugin RPC do. The client refetches totals after a room re-join and when coming back online, through the same coalescing window. The plugin SDK types learn the create fields the routes accept since #2154, on both hand-kept declarations.
2026-09-02 21:06:51 +02:00
Maurice 9cc427d156 fix(journey): gallery actions on tablets, tracks switch on phones (#2194)
Between 768 and 1023px the view-controls row is hidden and the provider and upload buttons had moved into it, leaving no way to add photos; the gallery actions render below the floating bar there now. The mobile journey settings sheet gets the linked-trip tracks switch the desktop dialog got, and the request gate and the fitBounds behaviour are pinned by tests.
2026-09-02 21:06:51 +02:00
Maurice 088eef6c6b fix(mobile,map): long toasts wrap and http selfhosts get the right geolocation message (#2095)
The phone toast was a single truncated pill, which cut the denied message exactly before the settings hint it exists for; long messages wrap up to four lines now. An insecure context is reported as its own error instead of masquerading as a blocked device permission.
2026-09-02 21:06:51 +02:00
Maurice e1af2049f9 fix(atlas): the note tooltip scrolls instead of zooming the map (#2153)
Leaflet's wheel handler on the map container cancels the native scroll, so the wheel zoomed the world under the note; the tooltip stops scroll and click propagation now. Hovering back to the marker no longer closes the tooltip mid-way, the flip threshold matches the real tooltip height, and a touch swipe scrolls the note instead of panning the map.
2026-09-02 21:06:51 +02:00
Maurice 5feced54d2 fix(studio): actually load the five book fonts the picker offers (#2183)
The @fontsource packages for Inter, Lora, EB Garamond, Playfair and Bebas were declared but never imported, so every pick fell through to its fallback stack, in the print path too. The faces load with the lazy Studio chunk now, in the exact weights BOOK_FONTS uses, including the MuseoModerno 500/600 the picker offered and nobody shipped. A guard test derives the required imports from the registry.
2026-09-02 21:06:51 +02:00
Maurice 2ab2dcc3dc fix(offline): the osm-de and stadia prefetch was CSP-blocked and the opening view stayed grey (#2180)
connect-src never listed tile.openstreetmap.de or tiles.stadiamaps.com, so every prefetch fetch was refused before the service worker saw it, while the swallowed error still counted as a cached tile. Both hosts are allowed now and only reached tiles count. The prefetch rectangle is also widened to the screen the fitBounds view fills, on the raster and the vector path alike; the places extent alone left grey bands beside a north-south trip.
2026-09-02 21:06:51 +02:00
Maurice fc6843528c fix(calendar): a fully timed stay keeps its description and location (#2136)
The markers replaced the booking's block but carried none of what it said: no DESCRIPTION (type, confirmation, notes) and no LOCATION fallback. The carrying booking is resolved once now, and the markers inherit its description and location exactly when the block is dropped. A check-in window ending before it starts reads as a late arrival past midnight instead of swallowing the event.
2026-09-02 21:06:51 +02:00
Maurice faa27e3608 fix(planner): a place shows every booking linked to it, not just the first (#2201)
Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
2026-09-02 21:06:42 +02:00
Maurice 5d849ec292 fix(journey): the gallery runs in trip chronology instead of upload order (#2200)
journey_photos.sort_order is nothing but the insert counter, so photos added late landed behind days they belong before. Both gallery readers (the app and the shared journey) order by capture time now, falling back to the timestamp of the stop the photo belongs to and then to upload time, through one shared SQL fragment; ties keep the old order, so existing galleries only move where chronology demands it. The order inside an entry stays the user's manual arrangement.
2026-09-02 21:06:42 +02:00
Maurice b6a86275f6 fix(notifications): the smtp test names its failure and the log carries it (#2196)
testSmtp logged nothing and the transport ran on nodemailer's two-minute defaults while the client gave up after eight seconds, so every failure was a generic toast over an empty log. The transport gets real timeouts, every outcome is logged without credentials, and a classified, scrubbed cause (auth, DNS, refused, filtered port, TLS mismatch, certificate) goes into the response the admin sees; the channel test calls wait long enough to receive it. The password field also rendered its mask as value, so replacing a stored password silently saved the bullets as part of the new one; it is a placeholder now, on desktop and the phone admin.
2026-09-02 21:06:42 +02:00
jubnl 1e6ab069cd Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
2026-09-02 18:42:16 +02:00
Claude 17668e5ff2 test: cover the new code paths SonarCloud flagged as unmeasured
The quality gate came in at 79.5% coverage on new code against a floor of 80%.
The gaps were real ones rather than a threshold quibble: three pieces of new
behaviour had no test at all.

- `useBagTotalsPing` was a new file with no test, and it holds the logic that
  stops one write turning into a storm of listBags requests on every connected
  client: the coalescing window, the in-flight guard, and the ping that arrives
  mid-flight and must still be served.

- The Journey Settings tracks switch had no test, including the part the review
  caught: that it refreshes rather than calling onSaved, which would close the
  dialog and drop unsaved edits.

- Neither bag panel had ever been rendered in a test. They now pin what the
  panels actually print: the server total rather than the sum of what the
  viewer can see, the grand total following the same rule as the rows above it,
  and the offline fallback.

Also covers the two ping call sites that were still unmeasured, the MCP
apply-template path and the plugin RPC bag delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 700918d533 test(packing): pin the bag-totals ping in the RPC and MCP fan-out expectations
Three plugin-RPC tests and the MCP bulk-import test assert the exact list of
broadcasts a write produces, so adding the #2191 weight ping to those two
surfaces made them fail — correctly: they are the contract for who hears what.

Updated to include it, and asserting it rather than just tolerating it. The
private-item cases are the interesting ones: the item event still reaches only
its viewers while the weight ping goes to the whole room, which is precisely
what makes a content-free ping safe there.

The bulk import gets one ping for the whole import rather than one per item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 98b97edb00 fix(packing,journey,server): address review findings on the three fixes
An adversarial review of the diff found sixteen defects that survived
verification. The substantive ones:

- The new `packing:bag-totals` event was never classified in wsEventPolicy,
  so the registry-parity ratchet was red. It belongs in
  HANDLED_OUTSIDE_TRIP_STORE: it carries no state to apply, which is the
  point of it.

- The Journey Settings tracks switch called onSaved(), which closes the
  dialog. That destroyed the switch the moment it was flipped AND skipped
  the unsaved-changes guard, so a title typed but not saved was silently
  lost. It refreshes instead.

- Deleting a bag sends no totals ping, yet packing_items.bag_id is
  ON DELETE SET NULL: everything in the bag lands in the unassigned pile and
  both figures move. The comment claiming otherwise was wrong.

- Only the REST handlers pinged. The MCP tools and the plugin RPC are two
  more live write paths, and since the displayed number now comes from the
  server rather than from the item events, weights simply stopped updating
  for anything written through them.

- Every item write pinged, including checking an item off — the most
  frequent packing write there is — and clearing twelve checked items sent
  twelve. The ping now fires only when a weight-relevant field changed, and
  the clients coalesce bursts and never overlap two refetches.

- Bags have no repo and no Dexie table, so offline the server totals froze
  at the last online read and went blind to the mutation queue. A stale
  absolute number measured against an airline limit is worse than an honest
  partial one, so offline the surfaces sum what they can see.

- SIGTERM arriving mid-bootstrap called server.close() on undefined, and the
  fire-and-forget promise turned that into an unhandled rejection: on Node 22
  a crash, which is the opposite of the fix.

- Promise.allSettled swallowed a rejected nestApp.close(), reporting a clean
  exit 0 while the crons and the plugin children were never torn down. This
  bug class is diagnosed from container logs.

Also: the track fetch gate read a stale `current` during journey-to-journey
navigation and fetched the previous journey's tracks; listBags ran the weight
aggregate twice per request, on a route the ping now fires constantly; the
grand total counted unassigned weight whose row was not rendered; the client
declared show_trip_tracks boolean while the wire carries SQLite 0|1; and the
six broadcast calls had no test, so removing all of them left the suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 639c40f8de fix(packing): a bag weighs what everyone put in it, not just what you can see
A bag's weight was summed in the browser over the item list the viewer is
allowed to see. Personal items belonging to other members are filtered out of
that list by design (#858), so their weight vanished with them: two people
looking at the same shared bag saw two different totals, and neither saw the
real one.

That number is then measured against `weight_limit_grams`, an absolute airline
limit. A per-viewer subtotal there is not merely incomplete but wrong — a
shared bag could sit over its limit and warn nobody.

The sum now happens in SQL over every row and ships as `total_weight_grams` on
the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the
grand total follow the same rule; a screen mixing true totals with per-viewer
ones would be worse than either. Only integers cross the wire: a member learns
that a bag is heavier than the items they can see, never a name, category,
quantity or owner. That disclosure is deliberate and bounded, and it is what
the issue asks for.

The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but
they are the one bringing it") is unchanged — it just no longer decides what a
bag weighs.

Totals also had to stay live, because the reported repro is two people looking
at the same screen. A private item's event reaches only its owner, which is the
very rule that made the totals wrong, so item writes now also ping the room
with a content-free `packing:bag-totals` and everyone re-reads the numbers.

All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read
the server figure and fall back to the local sum only for a bag cached offline
before this change.

Closes #2191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude b7f9c1e8ae feat(journey): make the linked trips' GPX tracks a switch, off by default
#1260 drew the GPX tracks of every trip behind a journey onto its map, with
nothing to switch off — the reasoning being that a route imported into a
linked trip is part of the journey's story. For a trip carrying a season of
recorded drives it is instead a map nobody asked for, drawn from places that
never became an entry.

So it becomes a journey-level setting, owner-only like the other Journey
Settings, and it starts off: a journal should show what its author put in it,
and #1260's set is everything the linked trips happen to contain. The switch
gates the request, not just the rendering — the endpoint ships full unthinned
geometry, which is megabytes fetched on every journey open for a map nobody
wanted.

`journeyTracks()` itself is deliberately untouched, so TREK Studio keeps the
set its saved books were laid out with.

Also fixes what the reporter's screenshot actually shows: JourneyMap fed track
coordinates into `fitBounds`, so one drive across a country zoomed the journey
map out until the entries were specks. Tracks now only frame the map when
there is nothing else to frame, which is what JourneyMapGL already did — the
same journey used to open at two different zooms depending on the renderer.

Closes #2194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 5e5fe288d8 fix(server): release sockets on shutdown so docker stop no longer ends in SIGKILL
Every `docker stop` of a TREK instance with a browser attached exited 137,
exactly ten seconds after the stop.

`http.Server.close()` stops new connections and then waits for the open ones
to end by themselves — and a WebSocket never does. TREK holds one per open
tab, so the close callback never ran. `closeDb()` and `process.exit(0)` lived
inside that callback, which left the only remaining exit a fallback timer set
to 10000ms: exactly when Docker sends SIGKILL. Docker starts counting when it
sends the signal and Node only once the handler is dispatched, so that timer
started strictly later and could never win. It was unreachable code in every
default deployment.

Waiting for Nest was not an option either: `WsAdapter.close()` is the one
place that terminates clients, and Nest reaches it only after every
onModuleDestroy hook has resolved — including the plugin supervisor's SIGTERM
grace for its forked children. The sockets holding the server open would be
hostage to the slowest hook in the container.

So the sockets are now released deliberately: ws clients get 1001 "going
away", idle keep-alive sockets are dropped at once, and anything still
attached after a 2s drain is destroyed. Nest is closed alongside that, the
database is closed on both exit paths rather than only the unreachable one,
and the fallback fires at 5s — inside Docker's grace, so a forced exit is
still ours.

The sequence moved into src/shutdown.ts behind injected dependencies. It was
untestable by construction before, which is part of why this went unnoticed;
it now has nine tests, including the signal that beats the bootstrap.

Closes #2193

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Maurice 2d460eadfb fix(pwa): dedicated safe-zone maskable icons for android launchers 2026-09-01 21:12:27 +02:00
Maurice 33e737e453 fix(mobile): keep the dashboard filter row from widening the layout viewport 2026-09-01 21:12:27 +02:00
Maurice 46a89e7a90 fix(map): surface geolocation failures as a localized toast 2026-09-01 19:24:38 +02:00
Maurice cfe2b80a5d fix(collections): mobile map fills the viewport like trip and journal maps 2026-09-01 19:24:38 +02:00
Maurice da2c881259 feat(admin): ai-parsing settings live in the tile shelf, compact 2026-09-01 18:18:08 +02:00
Maurice 5decdcfa13 fix(offline): cache osm-de and stadia tiles, prefetch down to the opening zoom (#2180) 2026-09-01 17:58:08 +02:00
Maurice 1e6f1fe95c fix(studio): text elements use the real font stacks, not the stale three-font map (#2183) 2026-09-01 17:58:08 +02:00
Maurice 674dededd3 Merge remote-tracking branch 'origin/main' into dev-mergeback
# Conflicts:
#	server/tests/unit/nest/calendar.service.test.ts
2026-09-01 17:21:23 +02:00
Maurice 5f63736b5d test(pages): wiring mocks track the new gallery-provider and weather-anchor contracts 2026-09-01 17:18:10 +02:00
Maurice 2d6454976e test(auth): passkey controller mocks need request headers now 2026-09-01 17:18:10 +02:00
Maurice c4fbf03928 fix(planner): guard the post-drag click swallow against a torn-down document 2026-09-01 17:18:10 +02:00
Maurice 03bd5c77af fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167) 2026-09-01 17:18:10 +02:00
Maurice c21e86e3bd feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163) 2026-09-01 17:18:10 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
Maurice 49b0ce28d5 fix(planner): no hotel bookend legs against a same-day carrier without stay times (#2157) 2026-09-01 17:18:10 +02:00
Maurice 2e0c496a93 fix(planner): edit details handoff from the transit journey dialog (#2148) 2026-09-01 17:18:10 +02:00
Maurice 2e00c998c0 fix(calendar): give stay check-in/check-out markers the one-hour handover window (#2136) 2026-09-01 17:18:10 +02:00
Maurice b9e8821114 fix(dashboard): no press-scale on composite trip cards (#2158) 2026-09-01 17:18:10 +02:00
Maurice a96bebc8bb feat(journey): photo provider buttons beside upload in the gallery header 2026-09-01 17:18:10 +02:00
Maurice 9f6cd3dc6d fix(collab): sanitize cross-user note markdown and harden note links 2026-09-01 17:18:10 +02:00
Maurice 37d7365707 feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) 2026-09-01 17:18:10 +02:00
Maurice 354a89f25b fix(todo): stop the press-scale animation from swallowing checkbox clicks (#2158) 2026-09-01 17:18:10 +02:00
Maurice 0a3ba7a4d7 fix(planner): keep a restored route toggle scoped to the selected day (#2019) 2026-09-01 17:18:10 +02:00
Maurice c59425947a fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165) 2026-09-01 17:18:10 +02:00
Maurice 4704bf5903 fix(auth): fail fast on passkey origins outside the relying party scope (#2147) 2026-09-01 17:18:10 +02:00
Maurice d5a3427cbb fix(packing): accept weight, bag and quantity on create across REST, MCP and plugin RPC (#2154) 2026-09-01 17:18:10 +02:00
Maurice a0c089baa4 test(memories): immich suites need the journey addon on now 2026-09-01 16:19:21 +02:00
Maurice fef5c39581 feat(admin): photo providers require the journey addon 2026-09-01 16:19:21 +02:00
Maurice 2834967919 fix(i18n): drop the self-hosted wording from the airtrail description 2026-09-01 16:19:21 +02:00
Maurice 3472d06e62 feat(admin): rebuild the addons page as a tile grid with sub-shelves
The three stacked sections become one grid of cards per group, with the
type shown on the tile itself. Bag tracking, the collab features and the
photo providers hang off their parent addon as compact shelf rows
instead of full-width siblings, and each group header counts how many of
its addons are on.

Along the way:
- naver_list_import, airtrail and llm_parsing get catalog entries, so
  their names and descriptions translate like everyone else
- dark detection moves into a shared useIsDark hook that watches the
  .dark class instead of re-deriving settings + matchMedia locally
- the ToggleSwitch knob uses --accent-text so a light accent in dark
  mode no longer renders white-on-white
2026-09-01 16:19:21 +02:00
Maurice 9bfdc19e68 fix(admin): naver_list_import is an integration, not a trip addon
It has no trip tab and no page of its own — it pulls places from an
external service, which is what the integration group is for. The type
only drives grouping in the admin UI (and the global nav check), so
existing installs just see the tile move; migration appended for them,
seed fixed for fresh ones.
2026-09-01 16:19:21 +02:00
RemEx 595a76321f fix(costs): show recorded settlement payments in the mobile Costs tab
Closes #2151

The desktop Costs view mixes recorded settle-up payments into the day-grouped
expense ledger, but the mobile screen only ever fetched balances/flows and
silently dropped the settlements array the server already returns — a
recorded payment cleared the balance but then left no trace anywhere on
mobile.
2026-09-01 14:39:37 +02:00
Cartoone9 537c450653 fix: boot in the account's language when offline
The store boots from localStorage, but 'app_language' is only written
on an explicit in-app choice; a language that lives in the account's
server settings never reaches the device. A PWA cold start with no
network then runs in English even though every online session ran in
the user's language, the same stranding #1618 fixed for currency and
units.

loadSettings now mirrors the fetched language into its own key
('app_language_server'), and the boot chain falls back to it between
the explicit choice and 'en'. A separate key on purpose: 'app_language'
means an explicit choice, and the login page's detection chain must
keep running for users who never made one.
2026-09-01 14:20:10 +02:00
Eugene Korchagin 92bb834f2d fix(atlas): keep long location-note tooltips on-screen and scrollable
Closes #2153

Bucket-list marker tooltips rendered as a single nowrap line, so long
notes overflowed off both edges of the viewport instead of wrapping.

Wrap and cap the tooltip's size, add a scrollbar for notes that still
exceed it (only shown when content actually overflows, and scoped to
this tooltip so short region/country tooltips are unaffected), and
make the tooltip interactive with a delayed close so the pointer can
reach it to scroll instead of Leaflet closing it the instant it leaves
the marker's tiny hit area. Also compute the tooltip's direction and
offset from the marker's on-screen position before it opens, so it
flips below the marker and stays clear of the left/right edges when
there's no room to render it centered above.

The positioning/scroll math is pure and covered by unit tests in
atlasModel.test.ts. Updated the L.marker mocks in AtlasPage.test.tsx
and useAtlas.test.tsx with on/off/getTooltip/closeTooltip so the new
marker event wiring doesn't crash under test.
2026-09-01 14:20:06 +02:00
jubnl 394201cd32 feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci]

* fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone

Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137

* fix(collections): ask the same question at all three call sites

savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.

* fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down

Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.

* fix(map): put the CARTO key back on the template the settings preview draws

The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.

* fix(admin): put the CARTO key back on the default-settings map preview too

Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.

* fix(mobile): put the CARTO key back on the phone map settings preview too

The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.

* fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar

Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.

* fix(mobile): the phone admin map preview needs the CARTO key too

The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.

* chore: bump version to 4.1.1 [skip ci]

* feat(plugins): admin editor for instance-wide plugin settings

The scope:'instance' settings a plugin declares had API endpoints but no UI.

- shared: plugins.schema.ts — the settings-field descriptor and the admin
  instance-config GET/PUT wire contracts, imported by both sides
- server: GET /api/admin/plugins/:id/config now returns the declared fields
  alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child
  reads config once, at init) and reports it via 'restarted'; the admin list
  carries instanceSettingsCount so the UI gates its menu item without a fetch
- client: 'Instance settings' row action opening a form modal (desktop) /
  MSheet (phone) in both admin shells — checkbox/select/secret rendering as
  in the user settings form; an untouched secret mask is never sent back
- i18n: three new admin.plugins.* keys in every locale

* refactor(plugins): move the admin body contracts to @trek/shared

The remaining plugin DTO schemas (install, link, activate, uninstall,
retrust, update, egress-hosts, user-settings update, route) now live in
shared/src/plugins/plugins.schema.ts with their inferred types, alongside
the instance-config contracts. The journey-contract doctrine notes (#1842)
moved with them — each body stays deliberately loose so the handlers keep
the rejections and fallbacks they already own, and new spec tests pin that
looseness so a well-meant tightening breaks a test that explains itself.

plugins.dto.ts is now thin createZodDto wrappers over the shared schemas
(same class names, no controller changes), and the client's pluginInstall
types its options and body against PluginInstallRequest.

* fix(admin): offer Allowed hosts only to plugins that declared operatorEgress

The row's egress chip was already gated on the declaration, but the kebab
menu (desktop) and row action sheet (phone) offered Allowed hosts on every
installed plugin — inviting the admin to widen egress for plugins that
never asked for it. Both entries now render only when the plugin declares
operatorEgress; the dialog's unsupported-notice fallback stays as the
truth-teller for stale list data.

* test(admin): close the Sonar gaps on the instance-settings form

The PR gate flagged new-code coverage (76.4%) and duplication (10.9%).

- The two identical 39-line handler blocks were logic, not markup — they
  move into one shared useInstanceSettings hook; each shell keeps its own
  markup (the deliberate desktop/phone divergence stays)
- New tests per shell cover the untested branches: every field type
  renders and round-trips into the save payload (select, checkbox/MToggle,
  number, hint, no-label key fallback), a failed fetch is a toast, a
  rejected save shows the server reason and keeps the edits, and the
  dialog/sheet closes without saving
- INS-009 covers the controller's no-body '|| {}' fallback

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-09-01 06:45:24 +02:00
jubnl 54b0e4eed4 Fix/sdk screenshot gate parity (#2164)
* fix(sdk): grade docs/screenshot.png exactly, like the registry gate

* docs(wiki): document plugin MCP tools and the exact screenshot gate

* test(sdk): cover the preflight screenshot probe
2026-08-31 14:32:53 +02:00
Konstantinos Thermos e615011e73 fix(journey): answer an entry edit with tags decoded, not the JSON string (#2155)
tags and pros_cons are JSON held in TEXT columns. Three read paths decoded
them by hand and createEntry and updateEntry did not, so an edit answered
with the raw row: tags came back as '["beach","sunset"]', the store spread
it into an entry the client types as string[], and the journey page threw
"tags.map is not a function" behind an error boundary.

Any edit was enough to arm it. The update path writes JSON.stringify(val)
with no length check, so even clearing an entry's tags stores '[]' and
answers with that string.

Neither compiler could see it: JourneyEntry describes the row, tags and
all, and both sides read the same field name while only one of them was
right. So the decoded shape gets a type of its own and one decoder, which
is what turned up the third case - the early return for an update that
changes nothing handed back a raw row too.

Fixes #2085
2026-08-31 14:32:00 +02:00
jubnl e70d7397dd fix(admin): let the plugin consent, signature and dependency dialogs scroll 2026-08-30 19:27:01 +02:00
github-actions[bot] 33a33e7b1d chore: bump version to 4.1.1 [skip ci] 2026-08-29 14:42:16 +00:00
Maurice 376332d989 fix(mobile): the phone admin map preview needs the CARTO key too
The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.
2026-08-29 16:40:54 +02:00
Maurice 43fbee94f9 fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar
Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
2026-08-29 16:40:54 +02:00
Maurice 0b2cf1c5ef fix(mobile): put the CARTO key back on the phone map settings preview too
The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.
2026-08-29 16:40:54 +02:00
Maurice 241f152347 fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.
2026-08-29 16:40:54 +02:00
Maurice 8c85323d6d fix(map): put the CARTO key back on the template the settings preview draws
The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.
2026-08-29 16:40:54 +02:00
Maurice 8635bec507 fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
2026-08-29 16:40:54 +02:00
Maurice 57637cb447 fix(collections): ask the same question at all three call sites
savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.
2026-08-29 16:40:54 +02:00
michael-bohr df9e2d542a fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone
Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137
2026-08-29 16:40:54 +02:00
Maurice f341efccc9 fix(mobile): the phone admin map preview needs the CARTO key too
The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.
2026-08-29 16:40:39 +02:00
Maurice 2cac1ccd4f fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar
Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
2026-08-29 16:14:17 +02:00
Maurice 9d33bd71da fix(mobile): put the CARTO key back on the phone map settings preview too
The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.
2026-08-29 16:13:31 +02:00
Maurice a4f8fabde0 fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.
2026-08-29 16:13:31 +02:00
Maurice 1984e151c1 fix(map): put the CARTO key back on the template the settings preview draws
The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.
2026-08-29 16:13:31 +02:00
Maurice dce348b947 fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
2026-08-29 16:13:31 +02:00
Maurice 78548ca2ba fix(collections): ask the same question at all three call sites
savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.
2026-08-29 16:04:36 +02:00
michael-bohr 8a0932df72 fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone
Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137
2026-08-29 16:04:36 +02:00
github-actions[bot] c87e6d2fc2 chore: bump version to 4.1.0 [skip ci] 2026-08-29 11:16:19 +00:00
Maurice e6ccaf89de Revert "chore: bump version to 4.1.0 [skip ci]"
This reverts commit 88ebf89ab3.
2026-08-29 13:15:22 +02:00
Maurice 061a3d91a0 fix(notices): keep the release modal up across the whole 4.x line
The 4.0.0 notice was scoped to a 4.0.x window on the assumption that each
release would bring its own entry. 4.1.0 shipped without one, so the modal
switched itself off and nothing took its place: the thank-you notice it
replaced is retired below 4.0.0, leaving a 4.1 install with no notice at all.

Hold it open to the 5.0.0 boundary instead. What it introduces is the 4.x
line, which is still what somebody arriving on 4.1 or 4.2 is meeting for the
first time, and a later major gets its own entry the way this one did.
2026-08-29 13:14:30 +02:00
github-actions[bot] 88ebf89ab3 chore: bump version to 4.1.0 [skip ci] 2026-08-29 10:59:15 +00:00
Maurice 9162f5d7ac fix(maps): stop the day route drawing drives to airports you flew from
A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
2026-08-29 12:23:30 +02:00
Maurice 3c865c9605 docs(wiki): document plugins:use
The scope that turns on third-party tool execution was the one missing from the
scope reference, and the count beside it was stale. It says what the scope does
not do, since the boundary is the admin's mcp:tools grant rather than anything on
the token.
2026-08-29 12:22:59 +02:00
Maurice a8a05ace40 fix(plugins): close the gaps between what a tool schema advertises and what it enforces
`.meta()` advertises every node of the declaration; `nodeToZod` only walks the
ones it can type. Everything below follows from that gap, so the keyword
allowlist moves into `sanitiseNode`, which is the one walker that sees every
node.

A `pattern` was compiled straight into a RegExp and run on the main thread
against caller input, with no timeout and nothing to abort it: `^(a+)+$` against
33 characters stops the whole server, not just MCP. That is an escape from the
supervised child into the host event loop, and it does not take malice, since a
hand-written email pattern is the classic accident. Quantified groups that repeat
or alternate inside are refused, and the source is capped.

`mcp:tools` counted as a side effect, so `readOnlyHint` was structurally
unreachable: holding that grant is what lets a plugin advertise a tool at all.
Every plugin tool was therefore advertised as potentially destructive and every
client's auto-approval was off. It is a host entry point like `hook:*`, and is
classified as one.

Property names, `default` and `examples` were the strings that never met the
sanitiser. A key may carry newlines, so `\n## SYSTEM\n...` in one defeated the
newline collapse everything else goes through. Names are identifiers now; sample
values keep their own keys but have their strings bounded.

Also refused at parse time, which is what the rest of the file already does: an
object `enum` member carrying `$ref` past the deep scan, a non-boolean
`additionalProperties` advertised and checked by nothing, `enum` beside
`properties` skipping the children, a root `enum`/`const`/`nullable` producing a
non-object schema the SDK cannot read, and a draft-4 `exclusiveMinimum: true`
dropped from the check while staying in the advertisement.

`required` used `in`, so a field named `toString` read as present on every call.
Nodes are built with a null prototype, and the three names Zod cannot round-trip
are refused rather than silently lost.

A result block that had to be shortened got no marker and no log line, which
reads to the model like the whole answer. It is counted now, like a dropped one,
and `isError` survives every envelope path.

The security test file carried two raw NUL bytes, so git, GitHub and ripgrep all
treated it as binary: no diff, no blame, no review. Escaped, same runtime value.
2026-08-29 12:22:59 +02:00
Renzo Beux 937e32924d fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the
model or the admin something it did not enforce.

Advertised schemas are now enforced. buildToolInputSchema built a loose
object that checked only which keys were present, so a tool advertising
an integer, an enum and additionalProperties: false accepted a string, an
unlisted value and an extra property. .meta() still advertises the
declaration verbatim, and the schema underneath it is now built from that
same declaration, so the two cannot disagree. Anything the builder cannot
enforce (oneOf, unknown formats, non-primitive enum members) is refused by
parseManifest rather than advertised, and the failure lands at install
time where an admin sees it.

Writing the test for that turned up a live bug: z.custom() cannot be
represented in JSON Schema, so building enums that way would have thrown
inside tools/list for the whole session. Enums and consts are real literal
types now.

readOnlyHint classification fails closed. The side-effect list spelled out
db:write: and missed db:create:trips entirely, letting a plugin that can
create trips advertise a tool as read-only and idempotent, which is what
clients gate auto-approval on. It is an allowlist of read-only grants now,
and mcp-tool-annotations.test.ts walks the whole catalog so a new
permission fails a test rather than producing a wrong hint.

The result cap covers the whole result. It bounded each text block, so a
hundred blocks cost a hundred times the limit: 6.5M characters in a probe,
now 65K. Large objects are serialised with a budget instead of stringified
whole and then sliced, and the block count is capped.

The admin tool list was dead code. The registry preview carries only
widget and tripPage, so the section added for it never rendered, and the
UI hid the description behind the title when both existed. The preview
carries sanitised tools now (previewManifest is exported and tested, which
is how the gap survived), and both fields render, description always.

plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a
registration that names no scopes got it and had it pre-selected at
consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by
name still works.

The public ManifestCapabilities gained mcpTools, so a typed manifest can
express what the runtime already validates.

MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022,
SDKP-015b/c.
2026-08-29 12:22:59 +02:00
Renzo Beux eb7d595203 feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the
admin can actually read it. Nothing rendered capabilities.mcpTools, so
approving mcp:tools meant approving text you would first meet inside a
chat.

The pre-install dialog now lists the tool names and descriptions, beside
the existing access and egress sections, and the grant gets an
at-a-glance chip like every other capability.

Desktop and mobile both, since the panels are parallel copies.
2026-08-29 12:22:59 +02:00
Renzo Beux 957cdffa7b test(mcp): end-to-end cover for plugin-contributed tools
Integration rather than unit, because what is being proved is that
nothing reaches the global exception filters. registerTools is called
outside the transport's try block, and mcp-transport.service.ts states
that every response there is written by hand and nothing may be thrown,
so a registry-level unit test cannot show it.

MCP-P15: a contributed tool that takes a built-in's name is skipped, the
built-in is the one still listed, and the session serves normally.
MCP-P16: a source that throws costs its tools and nothing else, where it
would previously have been a 500 on every /mcp initialize.
MCP-P17: a contributed tool call writes the same mcp.tool_call audit row
as a built-in, which is the whole reason the source hangs off
McpAttachOptions rather than the host calling registerTool itself.

MCP-P18 came out of writing the others: the first draft of MCP-P17 minted
a trips:read token and got no audit row, because the declarative access
marker had correctly hidden the tool. That is worth pinning.

mintOauthToken takes scopes now, and rpcResult parses the SSE frame the
transport answers with.
2026-08-29 12:22:59 +02:00
Renzo Beux 65a6a46d70 fix(plugins): invalidate MCP sessions on lifecycle and grant changes
The advertised tool surface is frozen at initialize, so anything that
changes which plugin tools exist has to end the sessions built before it.
tools/list_changed is not an alternative: no event store is configured,
and the surface would not be re-read anyway.

Unconditional, no diffing. The surface is per-session, since it depends
on the caller's scopes and the plugin's grants, so there is no ctx-free
set to compare against.

Five routes, mirroring what admin.controller.ts already does for addons:
activate, deactivate, update, retrust and uninstall. activate(id,
consent), update and retrust are also the grant-change paths, so "on an
mcp:tools grant change" needs nothing extra. A failed call invalidates
nothing, because nothing changed.

The addon cascade needed its own call. Disabling an addon can stop a
plugin that publishes tools, and the addon doing the cascading is not
necessarily one of MCP_RELEVANT_ADDONS, so mcpAffected does not cover it.
Still gated on a plugin actually stopping, which keeps the #1414 rule
that a config-only save kills nothing.

Imported from sessionManager rather than the src/mcp barrel, which
evaluates readEnv().mcp at module scope.

MCPINV-001 to MCPINV-007.
2026-08-29 12:22:59 +02:00
Renzo Beux 21fd04647f feat(plugins): advertise plugin tools on the MCP server
The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.

A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.

The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.

Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.

The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.

mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.

Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.

MCPTOOLS-001 to MCPTOOLS-019.
2026-08-29 12:22:59 +02:00
Renzo Beux c6027f9e68 feat(plugins): report implemented MCP tools at load
The child adds the tool names its mcpToolProvider hook implements to the
loaded payload, beside routes/jobs/exports, and the supervisor caches
them on Supervised like the rest. The hook itself already rides the
existing Object.keys(def.hooks) line, so the "did the child report the
hook too" half was free.

Declarative and synchronous: the host never asks the child what tools it
has. It intersects this list with the signed manifest, so a build that
reports a tool the manifest never declared is ignored rather than
trusted.

grantsOf comes along because the advertise path has to clamp what a
tool's annotations may claim against what the plugin was actually
granted, and sup.granted is already in memory. A per-tool DB read would
be the alternative.

plugin-host-entry.ts is excluded from coverage (it runs in a forked
subprocess), so the two supervisor integration cases are the only thing
proving any of this: the loaded payload, the acting-user-bound invoke,
and that both stop the moment the plugin is no longer active.
2026-08-29 12:22:59 +02:00
Renzo Beux 29de8dd9c1 feat(plugins): capabilities.mcpTools in the manifest
Tools are declared in the signed manifest, not only reported at load.
plugin-host-entry composes its loaded payload AFTER onLoad runs, so a
signed, sha256-pinned artifact could otherwise emit different tool text on
every restart with no version bump and no re-consent. The host will
advertise the intersection of the two.

It also gives the admin something to read. What mcp:tools will put in
front of every user is a manifest field now, so it can be shown on the
consent screen before the grant is given.

Rejects rather than drops, unlike most capability parsing: a malformed
tool should fail the install visibly, not become a tool that silently
never appears once the plugin is running. Caps and sanitising come from
mcp-tool-schema.ts, so the install path and the advertise path cannot
disagree about them.

Declaring tools without mcp:tools is an error on both sides. Otherwise
the consent screen lists tools that can never run.

No migration: install/discovery.ts already persists capabilities as JSON.

The SDK validator mirrors the checks it can make without the server's
normaliser, so `trek-plugin validate` catches a bad declaration before
publish rather than at install.
2026-08-29 12:22:59 +02:00
Renzo Beux 41de91668b feat(plugins): normalise and sanitise plugin-authored assistant text
The security control for plugin MCP tools, landed on its own so it is
reviewable as one diff. Pure and Nest-free: no container, no DB, no
running plugin needed to test it.

The threat is not a plugin crashing. A tool name, title and description
are a persistent, admin-invisible write into every user's assistant
context, and .meta() puts the JSON Schema there verbatim. TREK's own code
proves the channel is obeyed: the static-token deprecation notice is
delivered in a tool result precisely because that is what an assistant
reliably surfaces.

sanitiseAssistantText is a sibling of stripEmoji, not a change to it.
stripEmoji preserves newlines on purpose (PDF paragraphs) and ~15 render
call sites depend on that. The new one collapses every whitespace run to
a single space, so a description cannot forge "## System" as a heading
that reads to the model like host text, and strips more than the brief
asked for: the bidi overrides and isolates and U+2028/9 are neither C0
controls nor \n, so they defeat both halves of the stated rule.

The schema is advertised rather than converted. z.looseObject({}).meta()
passes the plugin's own schema through byte for byte, keeping pattern,
oneOf and format, which a hand-rolled converter flattens to unknown. The
.check() keeps required enforced, so the handler cannot receive a call
missing arguments its own schema declared mandatory.

That only works with a normaliser in front, since .meta() is a
passthrough. It rejects a root type that is not object (which would
advertise a non-object inputSchema while the runtime still passes an
object), rejects $ref/$defs/definitions at any depth, and caps bytes,
properties and nesting. It strips a top-level id, which vanishes from the
advertisement and lands in z.globalRegistry._idmap, a strong Map unlike
the WeakMap holding the rest.

Sanitising recurses into every nested description, title and string enum
member. That is the part a top-level cap misses, and where a property
named trek_session_token with a plausible description would live.

Annotations are clamped asymmetrically. readOnlyHint gates client
auto-approval, so a plugin may lower its danger level and never raise it;
openWorldHint goes the other way, since egress makes a tool open-world
whether it admits it or not. db:own is deliberately not a side effect:
counting the plugin's private SQLite would make nearly every plugin
non-read-only and kill auto-approval for legitimate read tools.

MCPSCHEMA-001 to MCPSCHEMA-030.
2026-08-29 12:22:59 +02:00
Renzo Beux 21f4a1241b feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host
dispatches INTO the child for every tool call, so it needs what
HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check,
the second gate in invokeHook, and the acting-user binding. That last one
is the point. The user is bound host-side from the supervisor's
invocation map, so a tool called by user A reaches A's data whatever the
model puts in args.

The permission keeps no hook: prefix because the surface it opens is MCP,
not a TREK render slot, and nothing keys off that prefix. It is the first
HOOK_PERMISSION value shaped that way; `satisfies` still holds.

One commit, because it cannot be split. requireTotalCoverage makes a
HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a
test failure, and PluginRpcRegistryService.onModuleInit runs in every
buildApp() — so a half-landed version breaks every integration test in the
repo. The generator is chained to it too: it refuses to run without both
the en admin label and the CLI hint.

15s is the longest budget of any read hook. The others back a render
someone is waiting on and fall back when they time out; this one backs a
chat turn, and the plugin is likely calling a third-party API of its own.
Still under the supervisor's unused 30s default, which is a hung client
rather than a budget.

One callTool fn for every tool, since tool names are runtime data and
hookContracts() is keyed by (hook, fn).

PluginDefinition exists in two copies with no generator between them, so
both get McpToolProvider. grantGaps and the dev mock host need no change:
both read HOOK_PERMISSION, which is generated.

PLUGHOOK-010, plus the CONTRACTS row.
2026-08-29 12:22:59 +02:00
Renzo Beux 092223c259 feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.

'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.

The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.

The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.

Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.

The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
2026-08-29 12:22:59 +02:00
Renzo Beux c56521b4a4 feat(server): process-level plugin MCP tool source
Sibling of plugin-event-sink.ts: the plugins layer registers a source, and
src/mcp/tools.ts reads it as a defaulted argument. mcp-transport/ is not
touched at all, so it never learns that plugins exist and the tool-call
audit row for a plugin tool comes out of code that did not change.

Pull rather than push, unlike the event sink, because the answer depends
on the session's ctx: there is nothing a push could carry ahead of time,
and reading it per attach means activating or uninstalling a plugin takes
effect on the next session with nothing to invalidate. Same argument
channel-registry.ts makes for setPluginChannelSource.

An unset source means "no plugin tools" rather than an error, the right
answer for any app built without the plugins runtime. MCPSINK-003 pins
that case, which none of the sibling sinks covers today.

Not try/caught here: attach() already contains a throwing source and logs
which one failed, and catching again would swallow that.

The harness takes dynamicTools explicitly so suites exercise the path
without installing a process-level source.

MCPSINK-001 to MCPSINK-005.
2026-08-29 12:22:59 +02:00
Renzo Beux a6c96f4053 feat(nest-mcp): per-session dynamic tool source on McpAttachOptions
A host can now contribute tools for one session through
McpAttachOptions.dynamicTools, consulted once per attach() with that
session's ctx.

It belongs here rather than in the host calling server.registerTool()
itself, because attach() is where the onInvoke seam fires. Anything
registered outside it is invisible to the tool-call audit trail, and
handlers that run as the calling user are the last thing that should sit
in an audit blind spot.

The source is synchronous: attach() runs inside the initialize request,
so an awaited source would put a third party's round trip in front of
every session creation.

Two properties carry the weight, both security ones:

- Names are reserved against every registered entry, not against what
  this session was granted. Reserving against the session would mean a
  token scoped to trips:read has no create_trip attached, so a
  contributed tool could take that name for exactly the callers holding
  no scope for it.
- Nothing a contributor does can make attach() throw. Hosts call it
  outside their request try block, so an escape is a 500 on every
  initialize and one bad contributor takes MCP down instance-wide. The
  source is contained once, each entry again. Both live throw sites are
  covered: the SDK duplicate-name throw, and the policy-less declarative
  access throw that registered entries only avoid because validate()
  pre-checks them at boot.

ToolOptions.inputSchema widens to ZodRawShape | McpZodSchema. The SDK has
accepted either since it normalises both to one object schema, and a
whole schema is what lets a host advertise one it did not author (via
.meta()) without converting it to Zod first. The arms are disjoint, so
nothing else moves; one test cast in decorators.test.ts records which arm
that decorator declared.

DYNTOOL-001 to DYNTOOL-014.
2026-08-29 12:22:59 +02:00
Renzo Beux b4f00571dc refactor(mcp): move invalidateMcpSessions into sessionManager
The body was a hand-inlined duplicate of sessionManager's own closeSession
helper, so it moves next to revokeUserSessions and reuses it. The barrel
re-exports it, leaving admin.service.ts and admin.controller.ts untouched.

The reason to move it is that callers outside src/mcp/ are coming. index.ts
evaluates readEnv().mcp at module scope and installs the sweep interval, so
importing the barrel from a domain module drags both into every test file
that mocks app-config with a partial export set, which is the hazard kill-switch.ts
documents. sessionManager.ts has no module-scope side effects.

The log line loses "due to addon state change"; addons stop being the only
thing that invalidates a session surface.

SESS-017 to SESS-020 cover it.
2026-08-29 12:22:59 +02:00
Maurice c8c11fdb7a fix(places): pass the mapped candidate to the SQL half, and state where the two still differ
The importer's parsed item spells the provider id `googleFtid`; the match rule
reads `google_ftid`. `isPlaceDuplicate` was handed a mapped object, but
`findDuplicatePlace` got the raw one, so its id strategy was always empty. The
name then decided the match, and a second place sharing that name could take the
ftid belonging to the renamed one. One candidate now serves all three calls.

Sharing the strategy list shares the ORDER, not the comparison, and two
differences survive it. SQLite `lower()` is ASCII-only where JavaScript's is not,
so a row stored as CAFÉ CENTRAL does not match the candidate Café Central in SQL
while it does in memory. The coordinate fallback used to paper over that for a
named candidate, sometimes with the wrong row. And `buildDedupSet` collects
coordinates only for unnamed rows while the coordinate query considers every row,
which is the answer `findMatchingPlaceId` wants and the opposite of what the
in-memory half says. Both are now written down where someone will read them, and
pinned by tests, rather than implied away.

PLACE-SVC-028e covers the backfill landing on the right row. PLACES-SVC-014 to
-017 cover the id beating a misleading name, both remaining divergences, and the
tolerance as a number rather than as a reference to itself.
2026-08-29 12:22:46 +02:00
michael-bohr 84b51a8af5 refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.

The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.

Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".

No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.

COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
2026-08-29 12:22:46 +02:00
Maurice 4854377603 fix(oidc): stop the login page signing a user back in against their will
Two reports, one line: the automatic bounce to the provider in useLogin,
which fires whenever password login is off. Both reporters are on an
OIDC-only install, which is what arms it.

#2126, the redirect loop. The re-entry guard sat inside the oidc_code
branch, but the exchange strips oidc_code from the URL before it
navigates away. Any re-run of the effect during that window - and t is a
dependency, so a language change is enough, which every non-English
install does on the login page - read an empty search, missed the guard,
fell through to the config probe and bounced to the provider. The
provider answered silently, landed back on /login?oidc_code=, and round
it went. Each pass mints a valid session, which is why closing the tab
and reopening lands the reporter logged in. The guard is now the first
statement of the effect, so no re-run can reach the redirect.

#2123, logout that logs you back in. The brake for the bounce was
noRedirect in router location state, and it does not survive the trip:
logout flips isAuthenticated on a sync lane while the router defers its
location update, so ProtectedRoute renders once on the old route and
emits <Navigate to=/login replace> with no state, overwriting the entry
the logout handler had just pushed. Location state cannot cross a full
document load either, and api/client.ts performs one on any 401. The
fact now lives in sessionStorage, set in authStore.logout() so all seven
sign-out call sites are covered, and cleared on any successful auth and
on the manual SSO links. Per tab, so a fresh tab still single-signs-on -
that being the point of single sign-on.

Both tests were confirmed red without their fix and green with it. The
first needed rewriting to do that: the obvious version held the page
mounted past the window the race lives in and passed either way.

Not included: RP-initiated logout at the provider. TREK clears its own
session correctly, and ending the provider's is a separate decision -
it needs an end_session_endpoint the provider may not advertise, a
post_logout_redirect_uri registered on their side, and an answer to
whether signing out of TREK should sign you out everywhere.
2026-08-29 01:30:26 +02:00
Maurice 8069b88057 test(admin): wait for the migration POST instead of the save toast
FE-ADMIN-STOR-036 went red on a loaded runner while every assertion in it
passed on the run before. The two storage tests that capture the migration
POST body read it immediately after awaiting the "Storage configuration
saved" toast - but that toast comes from the PUT, and AdminStoragePanel fires
the migration POST from a later effect reacting to the refreshed admin.state.
The two are not the same tick, so reading the body straight after the toast
only held while that effect happened to flush first.

Both now await the body through waitFor. A genuine regression still fails,
with the same assertion error, once the wait times out.
2026-08-29 00:51:43 +02:00
Maurice 44a117390b test(maps): stub maplibre-gl-leaflet globally
jsdom has no WebGL, so the real vector basemap layer can never work in the
client tests. More immediately: several map tests hand react-leaflet a partial
useMap() stub with no addLayer on it, which was harmless while the basemap was
a stubbed <TileLayer> and is not now that it is a real layer - addTo() reaches
for map.addLayer and rejects into the void. 79 unhandled rejections, every
assertion still green, and vitest fails the run on those.

Stubbed in the setup file for the same reason ResizeObserver is: the capability
does not exist in this environment. The tests that assert on the layer register
their own mock, which takes precedence.
2026-08-29 00:51:43 +02:00
Maurice b401da2264 fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
2026-08-29 00:51:43 +02:00
Maurice 1d12535b1b fix(map): position place pins in the map's render cadence on MapLibre
The place pins visibly swam back and forth while the map was dragged and only
settled onto their real position when it stopped. The clustered bubbles never did,
and that is the clue: those are drawn inside the canvas, the pins are HTML.

A library Marker repositions itself on the map's 'move' event, which fires once per
pointer sample, while the canvas is redrawn once per animation frame. When the style
is heavy or tiles are still streaming, the pins therefore run ahead of the map they
are supposed to be pinned to.

They now live in their own layer inside the canvas container and are positioned from
the map's own 'render' event, so pin and map move on the same clock. Mapbox keeps the
library marker: its terrain path needs the altitude handling that comes with it, and
the drift was never reported there. Clicks, hover and dragging a pin onto a day are
untouched — they hang off the element, which is the same element as before.
2026-08-28 22:21:01 +02:00
Konstantinos Thermos 9e0fb19e79 fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.

The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.

A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.

Fixes #2085
2026-08-28 22:01:06 +02:00
Maurice 8f6d5fd21f fix(dashboard): stand in for the trips and the stats while they load
The dashboard rendered its finished empty state while it was still fetching: no
hero, no cards, and a stats row reading zero everywhere. On a slow connection
that reads as "my trips are gone" rather than "not here yet", which is what the
report describes.

The stats row was the worse half. It comes from a separate call that carries no
loading flag at all, so every tile fell back to its zero until the response
landed. A dash placeholder says "not yet" where a 0 says "none".

The skeletons already existed. SpotlightSkeleton and TripCardSkeleton were
written for this page, described as matching its layout, and never wired up:
JourneyDetailPage was the only consumer of that module.

Fixes #2115
2026-08-28 21:20:59 +02:00
Maurice 71ff191bb4 fix(vacay): keep guest accounts out of the invite pickers
Guests are trip-scoped accounts. Trip Members excludes them from its picker and
refuses them on the write path, and Collections does the same, but Vacay's two
directories did not: a guest stayed selectable in a plan's Invite User dialog
and in the shared-calendar picker, including after being removed from the trip
it was created for.

The queries are server-side, so the filter belongs there rather than in the
client, which never received the flag to filter on. Both write paths get it too:
the picker only hands out a list, the id comes back from the client, and the
Vacay MCP tools reach the same two service methods.

Fixes #2112
2026-08-28 20:11:28 +02:00
Maurice 63a57b9269 fix(atlas): stop counting address abbreviations as countries
The Atlas hero counted more countries than the continent bars below it drew,
and more than the dashboard widget. The extra ones were invented: a place
without coordinates fell back to parsing its address, and any two-letter
uppercase segment at the end became a country code. "New York, NY" produced NY,
"Toronto, ON" produced ON.

Those phantoms only ever showed up in the one number. The continent strip files
an unknown code under 'Other' and never draws it, the map skips codes it cannot
resolve, and the dashboard builds its set from stored regions instead, which is
why it stayed right.

Filtering against a list of valid codes would not have been enough, and would
have hidden the worse half of this: 26 US state abbreviations are real ISO
country codes. CA is California far more often than Canada, DE is Delaware, IN
is Indiana. The existing comment above resolveCountryCode already describes that
collision and solves it by trusting coordinates first and sanity-checking the
address against the country's own bounding box. The gap it names in its last
line is this bug: with no coordinates there is nothing to check against.

So the bare-code branch is now only offered to callers that have coordinates. A
spelled-out country name still resolves either way. A lone "NY" or "CA" resolves
to nothing, which is the honest answer.

Fixes #2111
2026-08-28 20:11:28 +02:00
Maurice 4f15d87cbe fix(oidc): drop the old provider's avatar and identity when the IdP changes
Pointing the instance at a different OIDC provider left two things from the old
one on every account. The avatar kept a URL on a host this instance no longer
talks to, so it rendered as a broken image and stayed that way: the sync only
ran when the new provider sent a picture claim, and having no picture for a user
is a perfectly normal answer.

The account also stayed pinned to the old provider. Login still worked, because
the verified-email lookup finds the row, but the relink sat behind a check for a
missing sub, so oidc_sub and oidc_issuer never moved. The SSO note in settings
went on naming a provider that was gone.

An uploaded avatar is a bare filename rather than a URL and is left alone on
both paths, exactly as before.

Fixes #2110
2026-08-28 20:11:28 +02:00
Maurice adf88e1d61 fix(reservations): accept a booking that starts and ends on the same day
A permit valid for one day has no clock on either end. isEndBeforeStart filled
both missing times with midnight and then compared strictly, so start and end
came out equal and the form refused it: inline error, dead save button, toast
on submit.

It also made existing rows uneditable. A booking whose end is a bare date on the
start day is exactly what the booking import writes, and what the mobile sheet
has been allowed to save since 72a82b3c. Opening one of those on the desktop
left the save button dead with no way to clear it.

With a time missing on either side the booking is all-day, so only the dates are
compared. An end date before the start is still refused, with or without times,
and two explicit clocks still have to be in order.

The hotel guard comes along for the same reason mobile carries it: the date
panel is hidden for hotels, so switching type after typing dates left the button
disabled with its explanation inside the hidden block.

Fixes #2107
2026-08-28 18:34:47 +02:00
Maurice 910c62ea90 fix(transit): show the stop you get off at instead of the walk that follows
Every row of an expanded search result is anchored at its leg's start: the time,
the stop name, the platform. A leg's arrival appears as the next row's heading,
and the last one gets the closing row. The walking legs broke that and printed
their DESTINATION, which is the stop the next row already names.

MOTIS brackets every journey in walking legs, so the last leg of a real
connection is the walk from the arrival station to wherever you were headed.
Its origin is the stop you get off at, and because that row printed the
destination instead, the station had no row anywhere in the card and vanished.
The same branch blanked the time cell, so the train's arrival time went with it.
At the other end the access walk swallowed the origin the user had picked.

Rows now read from.name for every mode, and the walk says it is a walk in its
badge row instead. The platform tags along, which gives the exit row the arrival
platform it was already carrying. The time falls back to scheduledTime the way
the save path does, since a schedule-only feed would otherwise leave the row
that matters without a clock.

Fixes #2106
2026-08-28 18:34:47 +02:00
Maurice 6c7e257c17 test(plugins): cover the mock double and the child proxy for addEntryPhoto
The handler had eight cases, its two doubles had none, which Sonar counted as
new code without coverage. The mock is the more useful of the two to pin
anyway: an importer developing against it should hit the same refusals it will
hit in production, so the SVG, the missing extension, the empty payload and the
size cap are all asserted against the double rather than only against the host.
2026-08-28 18:04:25 +02:00
Maurice 661777f81f feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their
stories, and then it stops: there was no way to give an entry a picture. The
seven journal.* methods cover text and nothing else, so an importer reading an
export archive could only upload its photos as trip files, which live on a trip
and never reach the journey's gallery.

journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing
gallery photo and not a provider asset: an importer holding an archive has
neither, it has a file it just unpacked.

The access decision stays where it already was. addPhoto answers null unless the
acting user may edit the journey the entry belongs to, and null becomes
RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user
does. Everything the REST upload refuses, this refuses too: images only and no
SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The
stored filename is the host's, never the plugin's, because the gallery dedupes
on file_path. The object is written before any row points at it and removed
again when the link is refused.

JournalRpc moves into its own module for this. It now needs StorageService and
the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists
precisely so places, assignments and the plugin host can reach a few journey
functions without dragging both photo providers into their graphs.

Discussion #1365
2026-08-28 18:04:25 +02:00
Konstantinos Thermos 1c5c3ae983 fix(atlas): retile the map instead of rebuilding it when the CARTO key lands
useTileUrl returns a new string once carto_api_key reaches the settings
store, which happens after the first render. That string was a dependency
of the effect that *builds* the Leaflet map, so the key arriving tore the
map down and built a new one.

Two things break in that window: Leaflet finishes a queued redraw against
the canvas renderer whose map has gone ("Cannot read properties of
undefined (reading 'save')"), and the country layer never comes back —
its effect only re-runs when its own data changes, so the rebuilt map
shows tiles and nothing else.

The template belongs to the tile layer, not to the map. Hold the layers
in a ref, read the template through a ref inside the build effect, and
setUrl them from an effect of their own. Same fix in JourneyMap, where
cartoKey and mapTileUrl sat in the dep arrays of the effects that draw
every marker and track.

Fixes #2097
2026-08-28 16:04:38 +02:00
Maurice 5a7b8d75aa feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
2026-08-28 15:23:05 +02:00
Maurice 7cbd4e5c42 test(auth): cover the api-token routes
The three api-tokens endpoints landed without tests, which took
src/nest/auth/** under its coverage floor. Same four paths the
mcp-tokens block above already pins, plus the rate limit: unlike MCP
tokens these are not refused on a managed instance, so the limiter is
the only thing bounding a scripted caller.
2026-08-28 15:23:05 +02:00
Maurice 1e1a80c199 feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped
without saying so.

Places with no day were invisible, because the payload only reached places
through day_assignments. On a live instance that is half of them, and they
are the ones carrying a coordinate and no claim about when — exactly what an
integration can act on. They come back under `unplanned_places`, minus the
hotel, which is already reported as an accommodation.

Bookings with no day were dropped by the day filter. reservations.day_id is
nullable and deleting a day detaches its bookings rather than removing them,
so this is not a hypothetical.

The bucket list had no endpoint at all. It hangs off the user rather than a
trip, so it gets its own, and it answers whether or not the Atlas addon is
switched on: the addon decides what TREK shows, not what a key can read.

Also fixes ?include=notes returning the bare trip: places, notes and
reservations are reported on days, so asking for one now implies days
instead of quietly answering with nothing.

The module stays a leaf. Reaching for AtlasService pulled AuthModule and the
storage registry in behind it and broke the surface's own boot, so the one
SELECT lives here like every other query in the file.
2026-08-28 15:23:05 +02:00
Maurice d559fdda1d feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.

Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.

Translated into all 23 languages.
2026-08-28 15:23:05 +02:00
Maurice 4b120d39c8 feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.

Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.

Documents the surface in the wiki, since integrators need something to read
that is not the source.
2026-08-28 15:23:05 +02:00
Maurice 9cddf683a5 feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls.

Rate limiting is keyed by user rather than by IP. A self-hosted integration and
its owner's browser routinely leave the same address, and an IP bucket would let
one starve the other; 120 requests a minute is generous for a sync surface and
still bounded, so a runaway poll degrades its own integration instead of the
instance.

updated_at now ships with every trip, and its docblock says plainly what it is
not: a content timestamp. It moves when the trip's own fields change — title,
dates, currency, cover — and stays put when a place, a day or a note is edited,
because the child tables carry no updated_at of their own. That also rules out
the ?since= filter this was meant to enable: it would silently hide every trip
whose itinerary changed, which is worse than no filter at all. Making it honest
needs a schema change, not an API field, so the field is documented for what it
is and the filter is left out.
2026-08-28 15:23:05 +02:00
Maurice fd6381c814 feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no
version, and changes with the client. That is fine for our own frontend and
useless for anyone building against us — the Dawarich integration people have
been asking for since April needs something it can rely on not moving.

So this adds a small one rather than opening the existing 549 endpoints:

  GET /api/v1/trips           the trips the caller can reach
  GET /api/v1/trips/:id       one trip, ?include= picks the sections

Auth reuses the trek_ tokens users already manage under Settings, via Bearer
or X-API-Key. No new credential type, no new table — the guard calls the same
hash lookup the MCP transport does. It deliberately refuses a session JWT and,
for now, OAuth bearers: a credential whose scopes this surface does not yet
interpret is worse than one it rejects outright.

Access is decided per trip against canAccessTrip, the same owner-or-member
predicate as everywhere else, and a trip the caller may not read answers 404
identically to one that does not exist — a 403 would confirm the id is real and
turn the endpoint into a way to count someone else's trips.

The payload is not the rows. Ids, foreign keys and order_index stay inside;
accommodation day ids resolve to ISO dates, places arrive in planned order, and
every dated thing carries its date so a consumer can join on days rather than
on ids it has no way to look up.
2026-08-28 15:23:05 +02:00
Maurice d2402c798c fix(llm-parse): read an arrival that arrives as a bare clock again
fixArrivalDate normalized the arrival and then looked for the time behind a
leading date. The model usually sends the arrival as a bare clock, "13:00" with
no date on it, and slicing ten characters off that leaves nothing to match, so
the function returned early and stopped deriving the arrival date at all.

Try the bare 12-hour form first, then fall back to the normalized string, which
covers both shapes: "13:00", "01:11 pm", and a full date-time.
2026-08-28 14:25:08 +02:00
Maurice d091f4a815 fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.

No behaviour change: same inputs, same outputs, the spec is untouched.
2026-08-28 14:25:08 +02:00
Maurice 130272cfb0 fix(booking-import): stop writing an unreadable type down as a hotel (#2076)
When neither the keyword table nor the model produced a type, the router wrote
"hotel" and cast whatever string it got to FlatType without checking it. A hotel
is a booking type, so a ferry voucher imported from the Transport tab opened the
booking form, which offers six chips and not one transport among them, and
"other" was the only honest pick left. An unrecognised string got through the
cast too and then vanished silently in nuExtractToKiReservations.

The type is validated against FLAT_TYPES now. When it cannot be read the item is
still extracted with the lodging shape so it survives mapping, but it says so,
and the tie-breaker #2080 added finally has something to fire on: isUnplaceableItem
only ever matched a type outside both form's vocabularies, and the server can
only emit its own eight, so on a real document it never matched at all.

The tab travels with the job instead of sitting in component state. The parse
deliberately outlives navigation and reload and the review is triggered by the
global widget on any page, so by the time it ran the state had remounted back to
its default and the context was gone.
2026-08-28 14:25:08 +02:00
Maurice 016fdeec64 fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.

The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.

The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.
2026-08-28 14:25:08 +02:00
Maurice 15588d20c9 fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the
confirmation number into the DESCRIPTION, and the shared-trip payload does a
SELECT * over reservations with share_bookings on by default. Neither filters
anything, so whatever lands on a trip is published the moment it is written.
That is fine while a person types every booking in, and it stops being fine as
soon as something writes on their behalf.

reservations.ingest_state is that gate. It defaults to live, SQLite backfills
every existing row on the ALTER, and no current writer names the column, so
every booking visible today stays visible. Nothing writes staged yet.

The predicate takes its table alias because share.service.ts queried
reservations without one. A stay is withheld only when bookings point at it and
all of them are staged: a hand-added hotel has no booking at all and has to keep
showing up.
2026-08-28 14:25:08 +02:00
Maurice 8641788de0 docs(wiki): the new MCP surfaces
Files, settings, calendar feeds, invite links, the AirTrail import, provider
photos, help and the addon list. Also says why accepting an invite and
reading photo bytes have no tools.
2026-08-28 14:20:02 +02:00
Maurice 66530a074f feat(mcp): register the new surfaces, and point the model at the units
Ten new controllers in the test registry, and one line in the session
instructions: without it nothing tells an assistant to read the display
settings before rendering a temperature, which is what makes that tool worth
having.
2026-08-28 14:20:02 +02:00
Maurice 8a846e4b91 feat(mcp): read the warnings plugins raise about a trip
A plugin warning is a plugin telling the user something is wrong with their
trip, and get_trip_summary omitted them, so an assistant reviewed a trip
from a strictly smaller picture than the user was looking at.
2026-08-28 14:20:02 +02:00
Maurice fa67a29e76 feat(mcp): find provider photos and attach them to a journal entry
The whole memories domain had no tools, so the Immich and Synology
libraries were invisible even though the attach body is plain JSON and the
photos never pass through the call. Search, album listing, album contents,
and the attach.

The routes that stream photo bytes stay out: those are img targets.
2026-08-28 14:20:02 +02:00
Maurice dd469f7d1a feat(mcp): import flights from AirTrail
TREK's own bulk travel-history importer, and the closest thing in the
product to what this whole branch is about. Both halves are server-side
JSON, so neither was ever browser-bound; they simply had no tools. The list
tool's output carries what the import tool needs, so a model can run the
pair without a detour through the UI.
2026-08-28 14:20:02 +02:00
Maurice fa79923663 feat(mcp): calendar feed tokens and the trip invite link
export_trip_ics is a snapshot of one trip, so subscribing was out of reach
and the all-trips feed had no counterpart at all. Both feed tokens can now
be read, minted, rotated and revoked.

The invite link is the one that grants membership, which create_share_link
is not, and the descriptions say so in both directions so a model does not
reach for the wrong one. The two consumer routes stay out: the ics URLs are
what gets subscribed to, and accepting an invite is a human act.

test-db's reset list was missing trip_invite_tokens, so a leftover invite
re-attached itself to the next test's trip once ids restarted.
2026-08-28 14:20:02 +02:00
Maurice 9ed35d182f feat(mcp): the bundled help pages, and which addons are on
131 of 201 tools carry an addon gate, and the registry drops an entry when
either the addon gate or the scope gate fails: one filter, one outcome. The
session instructions tell the model to check what is available before
assuming a tool exists while giving it nothing to check with. list_addons is
that something, and it is the difference between a confused apology and
'ask your admin to enable Budget'.

The help routes were already public, so the two reader tools add no scope
surface at all. The asset route stays out: it serves bytes to an img tag.
2026-08-28 14:20:02 +02:00
Maurice ed67a5e9f8 feat(mcp): read and change display preferences
An assistant rendering a temperature, a distance or a clock time was
guessing: the session instructions named only the trip currency, and nothing
could read what the user had picked.

The write works off an allow-list rather than off the settings row, because
that row also holds the webhook URL, the ntfy token and the Mapbox, Carto
and LLM keys, and getUserSettings hands some of those back in cleartext.
Everything outside the list is refused, including a key nobody has heard of
yet. The guard test asserts against the service's own encrypted and masked
sets rather than a copy of them, so a sixth key added there fails it.
2026-08-28 14:20:02 +02:00
Maurice 6270495327 feat(mcp): read and organise the documents on a trip
Often the travel history is the uploaded PDFs, and an assistant could not
learn that a trip had any: there was no files.mcp.ts, no files scope, and no
files section in the trip summary.

Six tools: list, read contents, set description and primary link, add a
link, remove one, list them. Reading contents rides files:content rather
than files:read, because listing what a trip carries and reading what is
inside a booking PDF are different privileges.

The content read is not a second copy of the plugin path. readContent moved
out of files.rpc.ts into FilesService and both callers use it, so the 10 MB
cap, the storage-layer read and the off-thread decode stay in one place. The
RPC keeps its own error wording, which its tests still pin.
2026-08-28 14:20:02 +02:00
Maurice 42ba5d4849 fix(mcp): keep the categories module out of the auth graph
The admin gate on the three new category tools reached for AuthService, which
drags the storage registry in behind it, and the categories e2e builds a
container around one module against a two-table database. It stopped booting.

budget/ already solved this: isDemoUserId reads RuntimeEnvService and the
users table directly, and its module comments say why AuthModule is absent.
Categories now does the same, which also takes eight hand-built collaborators
out of its unit test.

The e2e gained RealtimeModule, which is global in the app graph but not in a
partial container, the way days.e2e.test.ts already imports it.
2026-08-28 14:20:02 +02:00
Maurice ab3751761c feat(mcp): files and settings scope groups
Two domains an assistant cannot reach at all today need somewhere to hang,
so this is the credential half on its own, ahead of the tools.

files:content is a third mode rather than a second group. Listing the
documents on a trip and reading the bytes inside a booking PDF are different
privileges, and the plugin host already draws that line between
db:read:files and db:read:files:content. It is not implied by files:write,
the same way journey:share is not implied by journey:write; the policy's
fallback branch covers both.

settings:write is scoped to display preferences. Settings also holds the
webhook URL, the ntfy token and the Mapbox, Carto and LLM keys, so the tools
that ride this scope work off an allow-list rather than off the settings
row.

34 scopes across 16 groups, translated into all 23 languages.
2026-08-28 14:20:02 +02:00
Maurice aa251cdb8e test(mcp): construct CategoriesMcp with the deps its tools now need
The three admin category tools gate on AuthService and the shared guards, so
the hand-built registry the MCP harness uses has to hand them over too.
2026-08-28 14:20:02 +02:00
Maurice 2ba10a9126 docs(wiki): the tools and fields added across the MCP surface
Also drops the em dashes from the earlier entries in this branch.
2026-08-28 14:20:02 +02:00
Maurice 44c2573abb feat(mcp): trip length, reminders, cover search, and the member permission
A dateless trip made by a tool silently got seven days, and reminder_days
was stuck on the default of three with no way to turn it off. Cover images
had no search at all.

add_trip_member and remove_trip_member hardcoded an owner check while REST
consults the configurable permission matrix and the plugin RPC does too:
three surfaces, two behaviours. They now use the same permission call
update_trip already made, so an instance that lowered member_manage behaves
consistently. The guest tools stay owner-only, because their REST routes are
owner-only by design and document it.
2026-08-28 14:20:02 +02:00
Maurice 4f98c5ea44 feat(mcp): collection membership, bulk visited marking, and upcoming bookings
Asking whether a place is already in one of your lists took a walk over
every collection and lost the coordinate tolerance the server matches on.
Marking a trip's places visited across every list holding them was one call
per place.

The dashboard's next-booking view is a three way union that folds in
synthetic check-in and check-out rows, so rebuilding it from per-trip
summaries silently drops the accommodations.
2026-08-28 14:20:02 +02:00
Maurice 9e21eb5d10 feat(mcp): note colours, note order, a note website, and category CRUD
A day note could not be coloured or positioned, so the service default of
9999 pinned every note an assistant wrote to the bottom of the timeline. A
collab note could not carry the website the card renders.

Categories could be assigned by bulk_update_places but never created: the
whole file was one read tool and a resource. The three new tools are
admin-only, matching the REST routes exactly.
2026-08-28 14:20:02 +02:00
Maurice 5d0fac3026 feat(mcp): place pictures, POI search, GPX export and a biased place search
image_url was in no places tool although the service persists it and the
picker in the UI sets a URL, which is not a file transport question.
search_place passed two of the service's four arguments, so an unbiased
query resolved to the wrong continent.

get_place_details gained expand for reviews and the editorial summary. It
defaults to off because that field mask bills as a Google Enterprise SKU,
and it now honours the admin kill switch the REST route checks: an instance
owner who turns Place Details off to stop the billing was still billable
through this one surface.
2026-08-28 14:20:02 +02:00
Maurice 922c60c167 feat(mcp): correct a journey entry, not just write one
location_name was settable when creating an entry and never afterwards,
which is the sharpest create-versus-update asymmetry in the surface.
Coordinates, weather, tags and the pros and cons verdict were persisted by
the insert but had no way in, so an entry written by an assistant never
placed on the journey map.
2026-08-28 14:20:02 +02:00
Maurice a5ac6b15e6 feat(mcp): resolve a coordinate to a region, and report the derived ones
mark_region_visited wanted a code like US-CA that nothing in the tool
surface could produce. The visited-region list also read the raw manual
marks while the UI reads the list derived from trip places, so the two
disagreed about where somebody had been. Travel stats gained the cities and
the distance the dashboard shows.
2026-08-28 14:20:02 +02:00
Maurice 4225d883c0 feat(mcp): half days, comp days, and the vacay plan settings
The toggle took a date and nothing else, then hardcoded a full vacation day
for the calling user, so a half day, a comp day or a day logged for another
plan member had no expression.

School holidays needed the plan flags that switch them on, and the region
list that makes them usable. That list needs care: a group region is stored
as COUNTRY|group:CODE and the reader treats a bare code as a subdivision, so
the tool now returns the string add_holiday_calendar actually wants. Belgium
and the Netherlands are the two countries this affects.
2026-08-28 14:20:02 +02:00
Maurice fed562d564 feat(mcp): bags, quantities and the sharing tiers of a packing item
Bag assignment was reachable only while bulk-importing a list, and by bag
name, never for an item that already exists. Quantity, weight and the
private flag were not reachable at all, and the whole three-tier sharing
model was invisible: an item made by a tool was always Common.
2026-08-28 14:20:02 +02:00
Maurice e0169388d3 feat(mcp): uneven expense splits, and the fields update was missing
Historical costs are rarely split evenly, and every budget tool took only a
list of member ids, which the service stores as a null amount, meaning an
equal share. members[] carries what each person owes instead.

A custom split has to reconcile before it is persisted, so the tool refuses
one whose shares do not add up in whole cents. Two ways round that check
needed closing with it: the write path drops a payer who is not on the trip
and then derives the total from what survived, and an empty payer list means
a total of zero rather than the previous one. Both would have certified a
split the stored row does not satisfy.

Also on update, which had none of them: currency (so the FX freeze is no
longer a no-op), expense_date, and a settlement currency, plus place_id on
create so a museum ticket can point at the museum.
2026-08-28 14:20:02 +02:00
Maurice f85a87e6d0 feat(mcp): reorder days and insert one mid-trip
An imported itinerary landed in whatever order the assistant appended it,
with no way to fix it afterwards: create_day only ever appended, and
days.service.insert was reachable from exactly one place in the server, the
REST controller. Because both halves were missing, inserting a day into the
middle of a trip could not be expressed at all.
2026-08-28 14:20:02 +02:00
Maurice f1bbd94f2b feat(mcp): booking link and end time on a reservation
url is persisted on create and update and rendered as a link by the
planner, and reservation_end_time is what makes a dinner run 19:00-21:30
rather than just starting. Neither was in a tool schema, so a booking
written by an assistant lost both - which for an imported itinerary is
every confirmation link it came with.

url validates through reservationUrlSchema from @trek/shared, the same
refinement the REST route applies, rather than a second hand-written one.
2026-08-28 14:20:02 +02:00
Maurice aa5002b2ed fix(mcp): keep transit out of the transport create enum
The transport form's picker has nine entries, not ten: a transit booking
carries a provider itinerary in metadata.transit and create_transit_journey
is what writes one, so letting a caller hand-make a bare transit row
produces a shape the transit UI does not expect.

Splits the one list in two - what may be asked for, and what counts as a
transport for the update gate - so a stored transit booking stays editable
through update_transport, which is where it was already reachable.

Also reports the ids set_reservation_travelers dropped instead of
returning a plain success with a shorter list.
2026-08-28 14:20:02 +02:00
Maurice 86c0c197db feat(mcp): set who is travelling on a booking
Reads have hydrated a booking's travellers since #1517; there was no way
to write them, so an imported booking could not record who was on it -
and creating a guest is only half an answer if the guest cannot then be
attached to the flight they were on.

Mirrors PUT /api/trips/:tripId/reservations/:id/travelers. The roster
filter stays in the service, so an off-trip id is dropped rather than
attached and the tool cannot be used to enumerate users.
2026-08-28 14:20:02 +02:00
Maurice 4b21a70ab2 feat(mcp): add, rename and delete trip guests
add_trip_member resolves an existing account by username or email, so a
travelling companion who never signed up could be put on a trip in the
planner and not through an assistant. A guest is assignable to budget
splits, packing, to-dos and day participants like any member, so leaving
them out of an import loses who was actually there.

Mirrors the owner-only /api/trips/:id/guests routes, including the
trip-scoped guard that keeps one trip's owner away from another trip's
guest and off a real member's user row.
2026-08-28 14:20:02 +02:00
Maurice 34522ad3bc fix(mcp): accept every transport type the planner offers
create_transport and update_transport took flight, train, car and cruise.
The picker in TransportModal has ten modes and ReservationsPanel renders
all of them, so a bus, a ferry or a taxi could be planned in the UI and
not through an assistant - which is where importing a travel history from
another service runs aground.

Both lists now come from one constant in the picker's order rather than
four literals repeated across two schemas, which is how they drifted.
legs[] stays on flight and train: the form writes metadata.legs for those
two and for nothing else.
2026-08-28 14:20:02 +02:00
jubnl d8fd2deb12 feat(sdk): allow new signing key (#2088) 2026-08-28 00:56:44 +02:00
Maurice 922f37ab52 fix(storage): ask the hook whether a storage job is running, not a stale closure
Every push to dev has been red since the 4.0.0 bump, always on the same test:
FE-MOB-MSTOR-015, where the second queued migration POSTs while the first is
still running. It passes on the PR runner and fails on the slower push one.

Instrumenting the queue effect logs the whole story:

  DEQUEUE files stateMigrations []
  APPLY refresh ["running"]
  START RESOLVED files error null
  DEQUEUE journey stateMigrations []

The refresh had already landed the running migration before the single-flight
lock was released, exactly as intended. What fires afterwards is the effect of
a commit scheduled BEFORE the POST, so its captured admin.state is the pre-POST
world, and it runs late enough that the lock is gone. The lock is a ref and is
read live; the "is one already running" test was read off that dead closure.
Under load the two drift apart and a second migration starts on top of a
running one.

So the hook answers the question instead. It keeps its world in a ref as well,
written where the response is applied rather than when the render lands, and
storageBusy() reads that. The queue effect still WAKES on admin.state, it just
no longer DECIDES on it, and the server's one-job-at-a-time rule now lives with
the hook that talks to the server rather than twice in two shells. Same shape as
the dirtyRef/savingRef the hook already keeps for the same reason.

An earlier attempt made the panels refresh again after startMigration, which was
both redundant (startMigration has awaited its own refresh since #1949) and no
fix at all: the stale read is on the next effect run, not on that promise.
2026-08-28 00:22:57 +02:00
Maurice f513881c01 ci: pin the platform order in the multi-arch manifest
The manifest list was assembled from a glob over the downloaded digest files,
which are named after the digest itself. The order was therefore alphabetical
by hash, and it changed from one release to the next: 3.4.1 lists amd64 first,
4.0.0 lists arm64 first.

That is not supposed to matter — a client is meant to pick the entry matching
its own platform — but several people updating from 3.4.1 to 4.0.0 on amd64
reported that `:latest` still resolved to 3.4.1 and only a pinned `:4` or
`:4.0.0` pulled the new image. Unraid came up twice. Content, format and digest
of the three tags are identical, so the flipped order is the only thing that
changed between the release that worked and the one that did not.

The digests are now collected per platform in a fixed order, amd64 first. That
needs the artifacts unmerged, since the per-platform folder name is the only
thing that says which digest belongs to which architecture.

Second thing the loop fixes: the glob happily built a single-platform manifest
if one of the two builds had failed to upload. It now fails loudly instead of
publishing a `:latest` that half the users cannot pull.
2026-08-27 23:28:35 +02:00
Maurice 92575c0d49 @
fix(import): stop reading tickets as lodging, and stop scroll escaping a dropdown

Two v4.0.0 reports.

The file importer classified nearly every transport document as a hotel. Its
keyword table tested lodging second, on a group that included "check-in" and
"check-out" — words printed on almost every ferry, train and bus ticket there
is — so a ferry ticket came back a hotel, and the review then handed the user
the booking form, whose six chips contain no transport type at all. That is
where "everything ends up as other" comes from: it was the only honest pick
left. Transport is tested first now, the lodging pattern asks for a word that
names lodging, and the rental pattern asks for a rental rather than for a
pick-up time that ferries and shuttles also print.

Two more in the same function. The flight path forces its type on everything it
returns, and it claimed any document with two letters followed by digits — which
is what a German rail number looks like, so an IC ticket was imported as a
flight without the keyword table ever running. It now stands down when the
document already names another kind of transport. And a type neither form can
express opened the booking form regardless; it now follows the tab the import
was started from, while a type either form knows still wins over the tab, since
one PDF routinely holds a flight and a hotel. An unreadable type reaching the
transport form arrives as transport_other rather than as a flight: a wrong
flight looks right enough to be saved unnoticed.

The other report: a dropdown inside a sheet scrolled the page instead of its own
list. The panel is portaled to document.body and positioned fixed, so its scroll
chain runs to the viewport rather than to the sheet it looks like it belongs to,
and nothing stopped it. Three portaled lists carry overscroll-behavior: contain
now — the select, the navigation menu (which reaches the phone through the place
sheet, not only the desktop inspector) and the phone day picker.
@
2026-08-27 21:35:03 +02:00
Maurice 4d6820a4d4 @
fix(planner): route a layover flight per leg, and count linked places as planned

Two v4.0.0 reports, both a surface answering a question with less than it had.

The map built its own waypoint order. It read the BOOKING position and never
expanded metadata.legs, while a multi-leg booking stores its position per leg —
so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and
the airport before it was joined to the airport after it in one driving run
across the Atlantic. It now takes the order from getMergedItems, the same one
the day plan renders, which brings the span filter and the leg expansion with
it. getMergedItems in turn had never read day_plan_position, the one slot the
map did read, which is how the two could disagree at all.

getTransportRouteEndpoints handed back the booking outermost airports for every
leg. A stop between two legs was therefore routed from the arrival airport back
to the departure one — a drive from Vancouver to Frankfurt with a cafe in
Reykjavik on the way. A leg now spans its own pair, when the endpoints line up
one per stop with the legs; anything ambiguous keeps the role lookup.

Being planned meant one thing: a day_assignments row. A hotel is linked through
its stay and a venue through its booking, and neither is ever dragged onto a
day, so both sat under Unplanned while the day header printed their names. The
three places that asked the question — desktop sidebar, map markers, phone
browser — now ask one helper that also counts a stay and a day-anchored
booking. A booking that names no day still counts as unplanned, because nothing
says when it happens, and the day plan renders nothing for it either.
@
2026-08-27 19:21:06 +02:00
Maurice 88c34f6415 @
test(calendar): cover the window paths the split added

The domain branch ratchet went from 93 to 92.3: the new hand-over code brought
branches with no case behind them. Three cases and one simplification, no
threshold touched.

Covered: a split rental whose endpoints carry no IANA zone and fall back to
their coordinates; a bare end clock, which resolves against the start date and
therefore does NOT split; and an unsplit rental with a day but no clock, whose
hand-over has nothing to place and correctly emits nothing.

The metadata parse now happens once, inside describeReservation, rather than
three times. The two extra copies were only there to hand `meta` to it, and one
of their arms is unreachable from SQLite, where the column is always text.
@
2026-08-27 18:19:33 +02:00
Maurice bbe4d62c90 @
fix(export): print the reader clock format, and split window bookings in the feed

Three v4.0.0 reports that all come down to a surface printing what is stored
instead of what the reader asked for.

The PDF (#2066) is assembled as an HTML string outside React, so no hook could
reach it and no time format was ever passed in. It printed the column: four
clock sites, all 24h whatever the personal setting or the admin default said,
and a place time typed by a 12h user came out as "3:00 PM" for a 24h reader.
Both export entry points now hand the format over the way they already hand
over the locale, and the four sites go through formatClockTime.

The journey editors (#2067) used a native <input type="time">, which paints
12h or 24h from the browser locale and cannot be told otherwise by any
attribute. Every other time field in TREK has used CustomTimePicker for exactly
that reason; the journey editor, its phone sheet and the phone place sheet were
never migrated, so the same place form obeyed the setting on desktop and
ignored it on a phone. The picker gained a disabled prop so the read-only
journey sheet keeps the affordance the native input gave it.

In the subscribed calendar (#2068), a booking that is a window rather than an
activity read as one block across its whole range: a week of airport parking
sat on all seven days. Both hand-overs now stand on their own — an hour each,
carrying what the block used to say — and the block goes. Only when the two
ends fall on different days: a same-day booking is one sitting, and a rental
with only its pickup geocoded keeps the block, which is the only thing holding
the return time and its zone at all. Rental cars were missing outright, because
the planner writes reservation_time = NULL when its optional time pickers are
left blank while still setting day_id, and nothing in the exporter ever read
the linked days. It reads them now, as the last resort, for every type — so a
hand-typed bus or train shows up as well.
@
2026-08-27 18:19:33 +02:00
Maurice eb3e0ca7c9 Merge pull request #2065 from liketrek/feat/upcoming-stays-and-status
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back
2026-08-27 16:20:26 +02:00
Maurice 0ca4dbaa94 Merge pull request #2069 from liketrek/fix/tests-pinned-to-3x
test(server): unpin three fixtures from the 3.x version window
2026-08-27 16:20:14 +02:00
Maurice a9b0cccb74 @
test(server): unpin three fixtures from the 3.x version window

The 4.0.0 bump turned three suites red on dev, all for the same reason: they
described a world where the host was still 3.x.

The plugin install fixtures declared trek ">=3.2.0 <4.0.0" and ">=3.0.0
<4.0.0", so every default artifact became incompatible with its own host and
23 tests failed on the version gate instead of on what they were testing. The
upper bound bought nothing — the cases that exercise the gate pass their own
range — so it is gone rather than moved to 5.0.0.

The system-notice suite named thank-you-support twice. It is capped at 4.0.0
and handed over to release-4-0-0 there, so the established-user case saw a
notice it had never been told about, and the per-version recurrence case rode
on a notice that no longer applies at all. The first now reads the always-on
set out of the registry instead of naming it, and the second drives the
mechanism through a notice it pushes itself. Neither ages out at the next
release.
@
2026-08-27 16:14:25 +02:00
Maurice f15a124016 @
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back

Three things, all in the same widget.

reservation_time holds two shapes. The booking form writes the datetime-local
YYYY-MM-DDTHH:MM; day-anchored rows, the demo seed among them, hold a bare
HH:MM. The query compared the column against an ISO timestamp as a string,
where ":" sorts above "2": every booking from 20:00 on passed the filter and
everything before it silently vanished, and the order ran off the same column.
On the demo that is why the Broadway show shows and the ferry does not. The
query now separates the date from the time before it filters, so a value with
no date falls back to the day it hangs off instead of being read as one, and
the order is by day and clock. The demo seed writes full timestamps, which is
the shape every real write path already produces.

Hotels came out of this list because a stay covers a range and a week in one
would hold a slot for the whole week. Arriving and leaving do not: they are
moments, and they are the two worth reminding someone of. Check-in and
check-out now appear as their own entries, named after the stay place and
falling back to the linked booking. The stay itself stays out. The two carry
the accommodation id, so the list key had to take the type with it.

An unconfirmed booking looked exactly like a confirmed one. The status was
already in the payload and is now a badge, on both the desktop tool and the
phone widget.
@
2026-08-27 15:38:45 +02:00
Maurice 99387812a1 Merge pull request #2063 from liketrek/main
chore: back-merge the 4.0.0 version bump into dev
2026-08-27 13:32:21 +02:00
github-actions[bot] a00b84b216 chore: bump version to 4.0.0 [skip ci] 2026-08-27 11:23:54 +00:00
jubnl 77dddd5e8a v4.0.0 (#1719)
Release branch for 4.0.0.
2026-08-27 13:22:39 +02:00
Maurice 4784a1e5dd fix(quality): clear the SonarQube findings on the 4.0.0 branch (#2062)
The day-swipe move handler mixed classifying the gesture with following the
finger, which put it at cognitive complexity 20. The classifying half is a
pure question about a gesture, so it moves out as classifyLock: null keeps
waiting for the next move, dead and h end the same way they did before.

Also reads the reduce-motion flag through dataset, seeds the closest-year
reduce with years[0] so it cannot throw if the empty guard above it ever
loosens, and compares the in-flight preview entry against undefined instead
of leaning on a promise being truthy.
2026-08-27 12:38:14 +02:00
Maurice de9bd94279 @ (#2061)
fix(plugin-sdk): stop the README heading match backtracking

`missingSections()` matched headings with `(.+?)\s*(?:<!--.*)?$`, where the
lazy body and the trailing `\s*` compete over the same run of spaces. That
backtracks quadratically: a heading line padded with 80k spaces takes about
12 seconds, 200k about 75. The gate runs over READMEs anyone can submit to
the registry, so the padding is not the author own input.

The trailing anchor comment is now cut with indexOf, and the body is matched
with its edges pinned to non-space, so nothing competes with the trailing
`\s*`. Same shape the wiki sidebar parser was rewritten to for the same
reason. Behaviour is unchanged on every heading that is not the attack:
trailing comment, unterminated comment, tab separator, padded title.

scripts/check-readme.mjs in the TREK-Plugins registry carries the same
regex and needs the same change.
@
2026-08-27 12:34:55 +02:00
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00
Maurice 521e52c853 fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and
fetches it from the server. Trip access was added to it earlier; this is the
rest of what that route needed.

The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0`
but not the IPv6 unspecified address: `::` starts with a colon, so the `0.`
check never saw it, and connecting to it lands on loopback. `http://[::]:9200/`
therefore passed the guard and came back with the target's title and og: tags
scraped out of the body — an exfiltrating read of anything listening locally,
from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same
address in other spellings and behaved the same way. The two `::ffff:`-prefix
regexes had the same shape of gap: they matched the dotted spelling of loopback
and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on
the expanded hextets, which covers every spelling at once, and fe80::/10 is
read as ten bits rather than as the four characters 'fe80'. This is in
ssrfGuard, so every caller gets it, not just collab.

The scrape could be turned into a denial of service. Each og: regex had two
unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it
made the engine rescan the remainder from every `<meta` it passed — quadratic,
and the byte cap admits half a megabyte. Measured on this build: 240KB of
'<meta ' took 58 seconds, and Node has one thread, so that is the entire
server, health check included, on one request. Bounding the runs at 512
characters makes it linear (540KB now takes 1.4s); no real attribute list comes
close to that.

The five-second timeout only covered the headers — `clearTimeout` ran before
the body was read — so a server that answered fast and then dripped one byte
every few seconds held the handler, the socket and the per-request undici agent
open indefinitely. The byte cap counts bytes and at that rate would never reach
one. `AbortSignal.timeout` covers both halves, the agent is closed in a
`finally`, and the three bail-outs that used to return without touching the
body now discard it, which is what cappedFetch's helper is there for.

The rest is smaller. Nothing metered the fetches, so an account with a trip of
its own could drive arbitrary outbound requests in a loop; there is a budget of
sixty new fetches a minute per user now, charged per outbound fetch rather than
per request. Previews are cached for ten minutes and simultaneous askers for
one URL share a single fetch, so the client's habit of requesting a preview per
rendered message — up to a hundred at once, with no debounce — costs one fetch
per new link rather than one per render. Refusals answer with one constant
reason instead of the guard's own: it distinguishes "could not resolve" from
"private address", which together map out the server's internal DNS for anyone
willing to guess hostnames. A body whose declared type is not markup is dropped
rather than combed for og: tags, and an og:image that is not an http(s) URL is
not passed to the client, which puts it straight into an <img src>.

Deliberately no `@RequirePermission` on the route: the client asks for a
preview while rendering a message or a note, never while composing one, so it
belongs with the GET siblings. Requiring collab_edit would blank every preview
for members of a trip whose owner narrowed that right while leaving them able
to read the chat, and it would not stop the abuse it looks like it addresses —
the permission check passes for a trip's owner at every level, and anyone can
create a trip.
2026-08-27 12:03:04 +02:00
Maurice 800f97b8ce feat(mobile): swipe the day details to step through days (#2059)
* @
feat(mobile): swipe the day details to step through days

The phone shell hangs the days off a chip rail pinned to the top of the
screen, which on a large phone is a thin target a thumb cannot reach one
handed. The day details fill the rest of the screen and did nothing.

They now take a horizontal swipe: left for the next day, right for the
previous one, with the panel following the finger, rubber banding at the
first and last day and springing back when the swipe falls short. The
chip rail is untouched and still the accessible path; a committed swipe
announces the day it reached through a live region, and the active chip
scrolls itself back into view once the rail overflows.

The gesture never calls preventDefault or stops propagation on a touch
event and sets no touch-action, so the browser keeps sole ownership of
the card scroll and of the header chip strip, and the long press reorder
bridge keeps its own 10px self cancel. In edit mode the swipe also
refuses to claim a press held past the bridge window.
@

* @
fix(mobile): line the map controls up and widen the POI bar

The compass sat on its own under the POI bar, centre top, while the
locate button floated 42px above the dock on the other end of the
screen. Both are one handed controls, so they now share the band just
above the dock: compass bottom left, locate bottom right, off the same
--bottom-nav-h the map already reads, a dock gap lower than before.

That frees the row under the chip rail, so the nearby places bar takes
the full width between the screen margins and its segments end up the
size of everything else the thumb aims at there. Desktop keeps the
floating, content width bar.
@
2026-08-27 12:01:11 +02:00
Maurice 495c5e95ec fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)
The UP NEXT card kept pointing at this morning's first stop hours after it had
started, and at day one of a trip that ended last month. A header promising what
comes next, sitting above something that is over. It now stays away on a day that
has passed and once today's timed plan has run out — a day whose stops carry no
times at all still shows its first stop, there is nothing stale about that. The
timeline card already collapses the reserved space when there is no up-next, so
the layout closes cleanly.

Seeding the plan day also lands on the next day still ahead when today falls into
a gap in the dates, instead of dropping back to day one. A trip that is already
over keeps opening on its first day.

And a day that was selected before the shell mounted now counts as seeded, so
clearing the selection is left alone instead of being re-seeded on the next
render — which is what the guard was there for in the first place.

Based on #2055.
2026-08-27 11:02:09 +02:00
Maurice f61bab3ffd fix(vacay): open on the current year instead of the last one added (#2056)
The year list comes back sorted, so picking its last element meant Vacay always
landed on the highest year — add 2030 to plan ahead and every visit from then on
opened 2030. It now opens the leave-year period today falls into, falls back to
the closest year the plan has when that period has none, and keeps the year the
viewer is on when a live update reloads the list.
2026-08-27 10:55:27 +02:00
jubnl d194d2cf6f chore(workflows): paths target on push 2026-08-26 17:12:12 +02:00
jubnl 1a126fb6fc chore(readme): add star history 2026-08-26 16:36:34 +02:00
Danette10 f605b730d8 fix(journey): improve video thumbnails and playback layout (#2043)
* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* feat: add PhotoGrid and PhotoImg components for journey photo gallery display

* feat: implement JourneyPublicPage to display shared journey data with timeline, map, and gallery views

* feat: create MobileEntryView component for displaying detailed journey entries on mobile devices

* feat: add docker-compose configuration for video testing environment

* feat: add gallery view components with support for local photo/video uploads and external provider integration

* fix: green up the video layout branch

Four things stood between this and a merge.

index.css carried committed merge conflict markers. A format-on-save had run
over the whole file and collapsed `=======` and `>>>>>>>` onto the line that
starts the popup-tip rule, which makes .trek-map-popup .mapboxgl-popup-tip part
of a malformed selector list - so the parser drops the whole rule and the map
popup's tail comes back as the stray notch its comment describes. Nothing in CI
catches that; invalid CSS just disappears. The file is back to the dev version
with only the video block added, 1301 -> 1323 lines instead of 2057, and the
same for the lockfile, which had only picked up "peer": true annotations from a
different npm run.

PhotoImg referenced an onClick it never took as a prop - the click already sits
on the button wrapping it.

PublicPhoto did not declare media_type even though journey-share.service.ts
selects it in both queries, which is what the `as any` casts around it were
working around. Declared once, the way JourneyPhoto does, and the casts are gone.

The single-photo hero had moved to a thumbnail. Right for a video, whose
/original is the video file and leaves an <img> blank - but a photo hero is
deliberately full size, which FE-JRN-PUBWIRE-008 pins. It now picks per media
type.

---------

Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-25 23:42:11 +02:00
jubnl 5b9aaab847 fix(client): mint upload idempotency keys via randomId()
uploadQueue called crypto.randomUUID() bare, which is undefined on
non-secure origins (plain-http localhost and LAN installs) and threw
instead of uploading. Route it through randomId() like the other two
idempotency-key producers so it falls back to getRandomValues, and
update randomId's header comment to name all three producers.
2026-08-25 23:16:18 +02:00
jubnl 0269273d01 fix(budget): use Math.trunc for user colour index, complete settlement fixtures
The `| 0` truncation in colorForUserId/colorFor existed to coerce an
undefined user_id to 0, but the settlement API always returns user_id —
only the two BudgetPanel test fixtures omitted it. Add user_id to the
fixtures, switch both helpers to Math.trunc (with a Number.isFinite
guard in colorForUserId preserving the old undefined->0 behaviour), and
drop nothing else: TravelerPicker only ever receives required numbers.
2026-08-25 23:16:12 +02:00
jubnl f5bfc321d7 chore(sonar): keep only categorical issue suppressions
Instance-specific findings (S2245 singles, S4790, S5332, S2819, S7767,
S4158 sessionManager, S8786, S6825) are resolved server-side as false
positive / accepted / reviewed-safe instead, so future violations of the
same rule in the same file still surface. Narrow the i18n secrets scope
to the locale directories so top-level code files stay fully scanned,
and drop the comment claims the code contradicts.
2026-08-25 23:16:05 +02:00
Maurice 105cb68316 fix(mobile): text fields no longer show a dark box inside their row
iOS paints its own background inside a text control, and under color-scheme:
dark that fill is darker than the card the field sits on. The mobile fields
are deliberately bg-transparent because the row supplies the surface - which
is why the native fill has nothing covering it and shows through as a dark
rectangle, while a desktop field hides it behind its own background.

Closes #2028
2026-08-25 23:02:13 +02:00
Maurice f052d9d26b docs: the storage seed example lives in the wiki now
Admin-Storage pointed at the README's Storage section for the seed file
example and the compose mount line. That section is gone, so the link went
nowhere and the example existed in no copy at all - the wiki page described
storage-config.json without ever showing one. Both are on the page now.

The commented mount in docker-compose.yml pointed at the same removed
section.
2026-08-25 22:54:54 +02:00
jubnl 167e2e9428 chore(readme): remove last part related to storage 2026-08-25 22:51:41 +02:00
Maurice ce584da168 docs: same spacing above every top-level heading in the README
Three headings sat directly on the paragraph above them while every other one
is preceded by a <br />. Sub-headings keep following their prose directly, as
they did before.
2026-08-25 22:49:13 +02:00
Maurice c563159072 docs: point the README at the wiki and the compose file it duplicates
Three more sections that only restated what is maintained elsewhere, and had
started to drift: the embedded compose example still pinned tmpfs at 64m where
docker-compose.yml has said 128m for a while, and it was missing the proxy,
language and kitinerary options the real file documents inline.

The headings stay, so the in-page nav link to #docker-compose-production and
any external link into these anchors still land.
2026-08-25 22:49:13 +02:00
Maurice 8087919d31 Remove storage configuration details from README
Removed detailed storage configuration and provisioning instructions from the README.
2026-08-25 22:49:13 +02:00
Maurice b597d71836 Fix formatting issues in README.md 2026-08-25 22:34:42 +02:00
jubnl 9bbbef6279 chore(workflows) add concurrency with cancel in progress 2026-08-25 22:23:56 +02:00
jubnl d4ecf96b64 chore(readme): add build status 2026-08-25 22:21:30 +02:00
jubnl b58515e47e chore(workflow): test workflow how now concurrency key 2026-08-25 22:15:00 +02:00
Maurice c435033343 refactor: one endpoint-name cleaner for both map renderers
The scan that replaced the parenthetical-stripping regex went into the Leaflet
overlay and its Mapbox GL counterpart as two byte-identical copies, which is
what the duplication gate caught. trackColors.ts next door exists for exactly
this reason - both renderers read a track's colour through one function so it
cannot come out different on one map than the other - so the cleaner follows it.

Behaviour is unchanged. The test brute-forces the new function against the regex
it replaced over all 55,987 names up to six characters of "ab() \t", and pins the
32k-space input that used to take a second.
2026-08-25 22:08:32 +02:00
Maurice a3ad53b3bf fix: close the backtracking patterns SonarCloud's profile turns up
The move to SonarCloud brought a wider rule set with it, and 23 more regexes
that can be made to hang on a crafted near-match. 21 are fixed here, each proven
equivalent to the pattern it replaces by brute force rather than by reading.

Two of them are reachable across users, not just by yourself. A reservation
endpoint name is free text any trip member types, stored and then read back in
every other member's browser while the map draws its labels, and the pattern
over it takes 2.3 seconds on a 32k paste - so one member could freeze the map
for the whole trip. The mobile transport sheet reads the same field. Both now
scan instead of backtrack, and both were matched against the desktop twin the
earlier sweep had already fixed rather than given a third spelling.

The markdown link stripper needed the same treatment as the tag stripper before
it: the label class cannot be disambiguated in the pattern, because it matches
an opening bracket as well, and narrowing it changes what "[a[b](c)" produces.
It is a scan now, with all four of the regex's quirks intact.

Also here: the two encoding warnings the scanner reported. A decorative comment
line in JourneyDetailPage.test.tsx had picked up a replacement character
somewhere along the way, and calendar.service.test.ts asserts on one
deliberately - that one is now written as a U+FFFD escape, so the file no longer
carries the raw bytes while the test says exactly what it said before.

Seven findings were deliberately not changed and are flagged in SonarCloud with
the reasoning. Five are false positives - patterns with no overlapping
quantifier pair, where the only cure would change how nested brackets parse.
Two are marked accepted rather than dismissed, because they are real: the readme
check's heading pattern has a proven linear replacement that cannot land until
the registry's copy of the same script can move with it, and
`export let JWT_SECRET` is what makes an admin's key rotation take effect
in-process instead of silently waiting for a restart.
2026-08-25 22:08:32 +02:00
jubnl b98363b2b0 chore(workflow): single workflow that runs the test AND the sonar gate (#2046) 2026-08-25 21:54:31 +02:00
jubnl 981a634dfb chore(sonar): switch to cloud 2026-08-25 19:42:45 +02:00
Maurice 62b13a278e refactor(common): one shared comparator instead of three inline copies
The coverage ratchet caught something worth keeping: an inline
`(a, b) => (a < b ? -1 : a > b ? 1 : 0)` is a new function with four branches at
every call site, and two of the three in src/nest/common sit on error paths that
a passing test never walks. Three restatements of the default sort order were
enough to pull the domain under its pinned thresholds (functions 97.8% against
99, branches 87.92% against 89).

Writing tests to walk those error paths would have been solving the wrong
problem. byCodeUnit says the same thing once, is covered once, and the callers
read better for it.

Its test asserts equality with a bare `.sort()` rather than hand-written
expectations, since "identical to the default order" is the whole contract. It
also pins what byCodeUnit is NOT: `['Zelt', 'Übernachtung']` keeps that order,
where localeCompare swaps it — so the next person reaching for a comparator on a
list a human reads picks the other one.
2026-08-25 19:26:38 +02:00
Maurice 071f516c0f refactor(llm-parse): use the shared tag stripper instead of a second copy
Two passes over the ReDoS findings arrived at the same scanner independently —
one replacing `/<[^>]+>/g` in the KML importer and the Commons metadata helper,
the other in the mail/HTML text extractor. The algorithm is the same down to the
edge cases, so the extractor now imports common/stripHtmlTags rather than
carrying its own.

Checked before folding them together rather than assumed: the two
implementations were compared over 2,441,406 generated strings and agree on
every one. The shared helper takes the replacement string as a parameter, which
is the only thing that differed — the extractor substitutes a space, the KML
importer nothing.

Two cases the shared helper's own test covers only in the abstract get a
concrete one here, since this is where the input is a file somebody uploaded: an
empty `<>` stays literal, and a `<` inside a tag is content, so
`3 < 4 and <b>5</b>` strips to `3 5` rather than keeping the first bracket. Both
are the old regex's behaviour, which is the point.
2026-08-25 19:26:38 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 52d1a0720f fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.

That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.

Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).

And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.

Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
2026-08-25 19:26:38 +02:00
jubnl 4ba407aead feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
2026-08-25 17:30:27 +02:00
cartoone b5c8182410 feat(planner): clicking the selected day header again deselects it
Same effect as the day panel's close button: selection cleared and the
day detail panel closed. Discussed in #2024.
2026-08-25 17:26:09 +02:00
cartoone 1cb67d5e64 feat(planner): scope the planned places filter to the selected day
With a day selected, the 'planned' filter now shows only that day's stops,
matching how the other filters follow the selection; with no day selected
it keeps showing the whole plan. 'unplanned' still uses the whole-trip
assigned set. Discussed in #2024.
2026-08-25 17:26:09 +02:00
Maurice fe5c6f182e fix(ci): restore the peer dep the swc install drops, and close a test race (#2040)
Two separate reasons v4.0.0 (#1719) is red.

The SonarQube workflow killed all 616 client test files with "Cannot find module
'@testing-library/dom'". That package is a peer dependency of
@testing-library/react and not a direct dependency of the client, and the step
that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm
not to put peer dependencies back when it re-resolves the tree. In test.yml that
step lives in the server job, which never runs client tests, so folding the jobs
into one (#2039) is what brought the two together. It now installs alongside the
swc binary, with its version read from the peer range rather than pinned, and a
one-line resolve check right after fails fast with the module name instead of six
hundred identical failures twenty minutes later.

MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the
panel. Its POST handler incremented postCount, then awaited request.json(), and
only then pushed to migrationPosts — so between the two there was a window where
one POST had been counted but migrationPosts was still empty. The GET handler
reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so
a poll landing in that window told the queue it was free to start the second
migration, and the waitFor(postCount === 1) above could resolve inside it too.
Parsing before counting closes it. Verified by running the whole mobile suite at
--maxWorkers=8, where it used to surface.
2026-08-25 17:09:29 +02:00
Maurice 6fe5009c20 ci(sonar): fold the four coverage jobs into one, on the root scripts (#2039)
Review feedback on #2038: the root already has a script that does all of this,
so the five jobs and the artifact shuffling between them were doing by hand what
`npm run test:cov` does on its own. It ends with `--prefix plugin-sdk`, so all
four packages are covered, and each writes its lcov to <pkg>/coverage/ — exactly
where sonar.javascript.lcov.reportPaths looks. 131 lines become 28.

Three things the plain root sequence still needs on a runner, kept as their own
steps so the reason stays visible:
- plugin-sdk is not a root workspace (workspaces = client, server, shared), so
  the root npm ci never reaches it and it needs its own.
- The lockfile was generated on Windows and omits @swc/core's Linux optional
  binary, so the server's SWC transform cannot load. test.yml carries the same
  step for the same reason.
- Only shared is built, not the root `build`. server and client run their suites
  from source through vitest, so building them adds a tsc pass and a full vite
  bundle that nothing here reads.

The cost is wall-clock. The first run of the parallel version took 16 minutes,
with client (11) and server (9) overlapping; sequentially that is closer to 25.
Nobody waits on this job, so the simpler file is the better trade — but it is a
trade, not a free win.
2026-08-25 16:34:08 +02:00
Maurice 616b39a9f0 ci: run the SonarQube analysis from Actions instead of by hand (#2038)
The scan has been a manual step on somebody's machine, which is why the
dashboard could sit on a stale commit without anyone noticing. This runs it on
every push to dev.

Push to dev only, and that is the point: the instance is Community Edition, so
it has no branch or pull-request analysis and the project holds exactly one
view. Analysing a feature branch would overwrite that view with the branch's
numbers until somebody scanned dev again, so the one branch that may publish is
the one everybody reads.

The four coverage jobs mirror the ones in test.yml because Sonar needs the lcov
each package emits — without it every line reports as uncovered and the
dashboard shows a collapse that did not happen. They run in parallel and hand
their reports to the scan job, which needs a full clone: Sonar reads git blame
to date each issue and to decide what counts as new code.

sonar.projectVersion stays pinned here rather than read from package.json.
package.json is still on 3.4.1 while the project has been analysed as 4.0.0 all
along, and since New Code is "previous version", taking the lower number would
silently re-base every new-code metric.

Needs two repository secrets before it can run: SONAR_TOKEN and SONAR_HOST_URL.
2026-08-25 15:51:10 +02:00
Maurice ad2119efd6 docs(wiki): correct what reading each page whole turned up (#2036)
The last pass reviewed diffs, so it never saw a new paragraph contradicting an
older one on the same page, or a long-standing claim nobody had checked.
Plugin authors were told ctx.config carries their per-user secrets and that
definePlugin takes a permissions key — neither is true, and both fail silently.
FORCE_HTTPS was said to need TRUST_PROXY, which it does not.
2026-08-25 15:22:46 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
Maurice 7da0080557 docs(wiki): bring the wiki in line with 4.0.0 (#2034)
* docs(wiki): correct the install and operations pages for 4.0.0

Quick Start passed docker an empty ENCRYPTION_KEY: an assignment prefix is not
expanded on the same command line, so the server silently generated its own key
instead of taking the one the page told you to set. The tag examples pinned to
the 3.x line, the documented tmpfs size did not match the shipped compose file,
and several .env values were described as reaching the container when compose
never interpolates them.

* docs(wiki): match the MCP reference to the registered surface

The scope table was missing the whole Collections group and the totals said 27
scopes across 13 groups where the code registers 29 across 14. The Cloudflare
link on the setup page pointed at an anchor on its own page instead of the
heading on Troubleshooting.

* docs(wiki): correct the plugin and admin pages

Three links to Plugin-Development carried a .md suffix, which does not resolve on
the wiki. The admin pages described tabs, permissions and storage behaviour that
had moved on.

* docs(wiki): bring the feature pages back in line with the app

Costs still documented the old panel, where categories could be added, renamed,
reordered and deleted; they are fixed now and the panel is a day-grouped ledger
with settlements. Trip sharing, journeys, maps, packing, reservations and the
account pages had all drifted in the same way.

* docs(wiki): cover the pages the first pass did not reach

The MCP catalog listed 168 of the 201 registered tools; the settlement tools and
rate_place were missing here and the Collections family from the addon page.
Several resource rows claimed a trips:* scope where the handler asks for
trips:read.

* docs(wiki): tighten the corrections a review picked apart

The audit-log resource is the row id, not the credential id; an admin password
reset does not bump password_version the way the page implied; and a few of the
new paragraphs described behaviour more confidently than the code supports.

* docs(wiki): fix what a last read-through caught

Costs shows every figure in your display currency, not the trip's, and an
expense with no payer still counts against the members it is split between.
Studio's cutoff is a 1024px viewport rather than phones alone, and the
Integrations tab is gated on the providers an admin has configured.

* docs(wiki): the last few the review caught

A passkey satisfies the MFA policy everywhere except the WebSocket handshake,
which still asks for TOTP, so a passkey-only account under that policy gets no
live updates. Mobile optimizes a day from two places, not three. The ctx.db row
carried an unescaped pipe that split the table, and the Backups table linked
with a wiki link whose pipe did the same.
2026-08-25 12:36:52 +02:00
Maurice dc74d59b11 fix(plugins): stop three ways a hostile archive or child could stall the host
All three sit in code whose whole job is to survive a plugin that misbehaves.

readTarGz trusted the tar size field, which parseInt reads with a leading minus.
At -512 the cursor advance cancels out and below it goes backwards, so the same
header is read forever while every pass appends a member — a 70-byte upload pins
the main thread and exhausts the heap, synchronously, taking HTTP, the socket
and the database with it. The entry budgets never get a say: they run on the
list readTarGz has to finish building first. Sideload is the reachable path.

The supervisor handed onMessage's promise to an EventEmitter listener, which
discards it. A rejection there is an unhandledRejection, the host has no net for
it, and Node ends the process — the supervisor killed by the plugin it contains.

dispatch() read `'_inv' in req.params` before handle()'s try/catch, so params
that are not an object threw a TypeError into exactly that discarded promise.
2026-08-25 11:41:07 +02:00
Maurice bc59289f73 fix(plugins): keep a plugin id from pointing outside its own directory
`POST /api/admin/plugins/:id/uninstall` walks its route parameter into
pluginCodeDir() and hands the result to a recursive remove. Express decodes
`%2F` after routing, so `..%2F..%2Fuploads` matches the route and reaches the
helper as `../../uploads` — path.join then lands in the uploads tree, and
`deleteData` gets the data root the same way. Verified against the express
version in the lockfile.

pluginCodeDir and pluginDataDir now reject an id that could leave its parent.
Narrower than the manifest's ID_RE on purpose: that rule wants 3-40 lowercase
characters and belongs at install time, while this one only has to make the id
unable to steer a path, so it stays permissive about length and case. Every
other filesystem sink in the backend already has its guard — storage keys,
extraction, the wiki, backup filenames. This one did not.
2026-08-25 11:41:07 +02:00
Maurice 40957e909b refactor: drop two returns that could only ever say one thing
Both functions had two exits handing back the identical value, which reads as a
decision and is none — Sonar's S3516, and the last two Blockers on the backend.

colorizeImportedTracks returned the very object it was given, coloured in place.
Saying `void` and letting the two importers return their own result is what the
function was doing all along. PLACES-SVC-005 checked the pass-through and now
checks the thing worth checking: an empty import writes no colours.

acceptInvite bailed out early on a refusal only to return the same result the
line below returns. The notification is what the early exit was really skipping,
so it is the notification that gets the condition.
2026-08-25 11:02:39 +02:00
Maurice fb76074e7c feat(mailer): say so when SMTP certificate verification is off
SMTP_SKIP_TLS_VERIFY (and the matching admin setting) turns off certificate
checking for outgoing mail. That escape hatch stays — an install behind an
internal relay with a self-signed certificate depends on it — but until now it
downgraded the connection without a word, and a transport is built per send, so
whoever flipped it had nothing to go on afterwards.

It now warns once per process, naming the host it applies to. Once, not per
mail: a line on every notification is a line nobody reads.
2026-08-25 11:02:39 +02:00
Maurice 23196aa038 refactor(demo): keep the demo password in one place
It was written out twice — once in the seeder that creates the account, once
in the config payload the login screen reads it from. The two have to match
exactly or the published demo credentials stop working, which is a poor thing
to leave to copy-paste. It moves next to DEMO_EMAIL_PRIMARY, which the same
comment block already argues for.

Also clears a Sonar S2068 "hard-coded password" on the auth service: the value
is public on purpose, but a literal next to a `demo_password` key is exactly
what the rule looks for.
2026-08-25 11:02:39 +02:00
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00
jubnl decc0e1a12 fix(plugins): boot enabled plugins after the rpc-registry scan, not before
PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.

Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
2026-08-25 08:06:50 +02:00
jubnl 4548743874 chore(packages): add test coverage support to plugin sdk and shared package 2026-08-25 01:22:20 +02:00
jubnl 190dc482e4 fix(sonar): use resourceKey in the i18n issue-ignore entries 2026-08-25 00:49:12 +02:00
jubnl efcdbd94fb fix(client): compute "today" from the local calendar, not UTC 2026-08-25 00:44:42 +02:00
jubnl 67b71a52d1 chore(sonar): stop secret detectors false-positiving on locale files 2026-08-25 00:11:58 +02:00
jubnl bd33d0b1cf chore(sonar): scope shared specs as tests and drop a stale exclusion 2026-08-25 00:06:50 +02:00
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00
Maurice 4bac447ac8 fix(planner): draw only the selected day's automated transports
Closes #2019

The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.

Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.

Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.

The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
2026-08-23 23:43:14 +02:00
Maurice 6779cd2dd1 fix(studio): fit an outline map to the country the trip was in
Closes #2021

A country is one path but not one landmass. France's is five rings, three of
them Kerguelen, French Guiana and New Caledonia, so fitting a map to its
bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice
route as a single dot. Britain does it through South Georgia, Norway through
Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey
looks perfect and a France one does not.

Satellite only escaped by accident: imagery leaves the shape list empty, so the
fit fell through to the stops. Same control, silently doing nothing.

Fit to the rings the journey is actually near, and keep the whole shape as the
fallback for a map with no route yet. The margin is the route's own size with a
two-degree floor, which is the judgement in this fix: at two degrees a
Paris-Nice route still keeps Corsica, which is the picture that trip wants,
while Kerguelen stays out.

The outlines themselves are untouched — the overseas rings are still drawn,
they just run off the frame the way a coastline does.
2026-08-23 23:43:14 +02:00
Maurice 477e44ef50 fix(journey): keep the mobile timeline at the bottom of the screen
Closes #2022

The story preview carried both `block` and `line-clamp-2`. Tailwind emits the
display plugin after line-clamp, same specificity, so `display:block` won and
`-webkit-line-clamp` — which only does anything on a -webkit-box — was inert.
The whole entry text rendered, the card grew with it, and the timeline it sits
in is bottom-anchored with no height of its own, so it climbed up the screen.
Measured at 430x932: three words put its top edge at y=744, four hundred words
at y=24, two thousand at y=-2916. That is the reporter's "at the top in one
journey, in the middle in another" — it is not the install, it is the longest
comment in the journey being opened.

The same growth explains the other half. The timeline sits above the map and is
a horizontal scroller, so once it covers the screen it takes every drag Leaflet
would have got, and only the gap between it and the dock still reaches the map.

Dropping the display utility fixes today's cards; capping the card height means
no future one can do this again. 140px is what the older journey timeline pins
its active card to, so the two look like the same product.

Also lift the map attribution over the dock, which the reporter's screenshots
show sitting on top of it. Both engines, since a phone meets either.
2026-08-23 23:43:14 +02:00
Maurice 4eee08e45c test(middleware): cover the branches the forced-HTTPS change added
The redirect grew three paths nothing exercised: a malformed APP_URL falling
back to the request host, a request the proxy already terminated TLS for, and
the health probe, which talks plain HTTP on the loopback and must not be sent
to a hostname it cannot resolve. The per-domain coverage ratchet caught the
gap - src/middleware sat at 93.44% against its 94% pin.
2026-08-23 11:59:12 +02:00
Maurice 44ddfa510e fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
2026-08-23 11:59:12 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00
Maurice 502a3fd4e8 fix(shared): express the URL scheme check without a control-character regex
Same behaviour, and the root lint job stops failing on no-control-regex. The
stripping is the point and has to stay: browsers drop everything at or below
U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is
still a javascript: URL to them.
2026-08-23 11:59:12 +02:00
Maurice 38f3cb3101 fix(server): the second pass over this branch's own fixes
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.

The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.

The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.

And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.

The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.

TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.

Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
2026-08-23 11:59:12 +02:00
Maurice 3e0bfbbb29 fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
2026-08-23 11:59:12 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00
Maurice c75e958fc0 fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the
file did not parse and vitest reported it as "no tests". Both guards in it
were dead - including the byte-identical egress-policy check the package's own
notes call the worst possible bug in that module to lose. No CI job runs the
SDK suite, so it landed quietly; the next npm publish would have failed on it,
since prepublishOnly runs the tests.

Also in the SDK: the dev preview answered trek:geolocation without checking
the geolocation:read grant, so a plugin whose manifest is missing the
permission works perfectly in dev and is dead once installed. That is the
lenient direction, which is the one that ships broken plugins.

Client side: the background-task poll never dismissed a 404'd import job and
spun forever, "Update All" collapsed its busy flag after the first plugin
settled, the lightbox had no cancellation so a slow signed URL rendered the
wrong image, the plugin geolocation watch was torn down by any unrelated
settings write, manual date entry accepted 31 February, cover previews were
never revoked, and check-all issued one request per packing item.
2026-08-23 11:59:12 +02:00
Maurice b3d8bf08f1 fix(mobile): local dates, revoked object URLs, and writes that said nothing
Four screens derived "today" from toISOString(), which is the UTC calendar
day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went
overdue eight hours early in Tokyo, and a new journal entry got the wrong day
either side of midnight. All four take the viewer's own calendar day now,
through the helper the planner already had.

Three sheets built preview object URLs in render and never revoked them, so
scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes
from inside a setState updater, which StrictMode double-invokes.

And several writes failed in silence: the notification toggle, the collab
feature switch rolling back to a stale snapshot, the appearance change whose
debounced write was dropped on unmount so it reverted on the next reload. The
backup-code print page skipped the escaping its desktop twin does, the
clipboard copy threw on a non-HTTPS install, and reservation metadata was
JSON.parsed unguarded in render, which takes the whole route down.
2026-08-23 11:59:12 +02:00
Maurice 19745b7b0d fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but
never re-queued the local document, and write() had already cleared the
queue before the request. So if the user picked Keep mine and then simply
closed the editor without typing anything else, saveNow() found nothing
pending and did nothing: their afternoon of edits existed only in memory,
the indicator said idle, and the server kept the other version. The exact
outcome the conflict dialog promises to prevent.

Ctrl+Z was intercepted while typing in a text field too. Studio commits text
on blur, so mid-edit the typing is not in the undo history yet: the browser's
own text undo was suppressed and a previous layout change was undone
underneath the open editor instead. Reads as random data loss.

Also: delete no longer removes locked elements, the auto-layout no longer
freezes an empty GPS path when tracks resolve after the one-shot build, the
back-cover tally cannot print "0 places", the map fetch aborts when Studio
closes, and a double Enter in the list-name field stops creating two lists.

The page-pattern gate had quietly stopped covering anything: the Desktop-shim
refactor moved every real container out of the default export the checker
reads, and its regex missed useRef<T>(null) anyway. Widened on both counts,
and the state it immediately found moved into the hooks.
2026-08-23 11:59:12 +02:00
Maurice 7959470802 fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly
The notes and polls WebSocket handlers applied every event they saw without
checking which trip it belonged to, so an event for another trip the user is
a member of landed in the open one. The poll load effect had no cancellation
either, so switching trips fast let the old response win.

What's Next compared trip-local calendar dates against a UTC one while
building the time from the local clock. West of UTC that means the rest of
today disappears from the widget every evening and tomorrow's items are
labelled Today; east of UTC it happens after local midnight instead.

A note website was rendered as a raw href with no scheme check, the same
stored-javascript: shape the planner had. Category rename walked the notes in
a loop with no handling for a partial failure. And the school-holiday region
labels resolved German first for every locale.
2026-08-23 11:59:12 +02:00
Maurice 61d4fd4d64 fix(planner): scheme-check the booking URL, and stop the serial routing calls
A booking's url is rendered as a plain href to every other member of the trip
and nothing ever checked its scheme, so a collaborator could store
javascript: there. The place website next to it has been going through
safeHttpUrl for exactly this reason, with a comment saying stored values
predate the server check - this is the surface that was left. Both ends now:
safeHttpUrl at the render site, and the scheme in the shared contract so
nothing new gets stored, additively so existing rows still load.

The per-leg routing rewrite turned one multi-waypoint request per run into
one serial request per leg. A twelve-stop day is eleven round trips to a
public OSRM in sequence before a single connector appears, and toggling km/mi
does it all again. Group consecutive legs that resolve to the same mode into
one request and fire the groups concurrently, keeping leg order on the join.

Transport reordering wrote to the store and persisted with a bare catch, so
a failed PUT left the sidebar showing an order the server never got; it
reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked,
expand/collapse-all persisting to a different storage than the day list reads,
the debounced address search with no ordering guard, the accommodation save
chain with no error path, and window.__trek_map holding a destroyed map.
2026-08-23 11:59:12 +02:00
Maurice 47a4276e84 fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers
them is App's mount effect - which never runs again, because logout navigates
through the router rather than reloading. Log out, log back in, go offline,
edit something: it queues and nothing ever flushes it. No online listener, no
visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth
success instead; registerSyncTriggers is already idempotent.

The background sync loops only checked the auth gate on entry. Logout swaps
the Dexie handle to the shared anonymous database, so a trip loop or a blob
download still in flight afterwards wrote the logged-out account's itinerary
and documents into it - the database that survives logout and greets the next
person on a shared device. Check the gate per iteration.

And the remote place:updated applier replaced the whole embedded place, while
the local reducer right next to it preserves the assignment's own times. Same
event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the
itinerary when someone else renames the place. One merge helper now, used by
both, so they cannot drift again. The test that should have caught it seeded
null times and asserted only the name.

Also: moving a day note deleted before it created, and restored the note only
locally when the create failed; t() interpolation was corrupted by a '$' in a
parameter value; a 429 upload was treated as permanently failed; and the
geolocation watch was started inside a state updater.
2026-08-23 11:59:12 +02:00
Maurice eadb3703bc fix(shared,tests): bound the book contract and close the gaps that hid all this
Book page setup and element geometry were unbounded numbers, so a shared
book could be saved with zero, negative or astronomical dimensions and every
consumer downstream - the canvas, the PDF export - had to cope. Day-note caps
had drifted from the limits their own doc comment states, and icon had none.

On the test side: the path filter meant a PR touching only the root lockfile
ran no jobs at all, which is exactly the change class most likely to break a
native binding; the canAccessTrip mock is hand-copied into 72 files and had
already drifted from the real query; the path-traversal test asserted only
status !== 200, so a 500 from a broken guard passed it; and one unit test
shelled out to a real kitinerary binary if the host happened to have one.
2026-08-23 11:59:12 +02:00
Maurice a7aaa29c16 fix(db): stop losing secrets and notes to the boot and migration paths
An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
2026-08-23 11:59:12 +02:00
Maurice 8e5fdaa877 fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during
the load cached permissive defaults and every later check answered from that
cache - fail-open, and it never expired. The rate limiter grew its per-IP map
forever; the GC the legacy version had was dropped in the fold. Idempotency
did a check-then-insert, so two concurrent duplicates both ran the handler.

Audit rows recorded whatever X-Forwarded-For said, which the client writes,
and the request-body debug logger redacted by exact key match - it missed
refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect
from the client's Host header. CSP asked for unsafe-eval on top of the
wasm-unsafe-eval it actually needs.

The OAuth deny path redirected to an unvalidated URI, ws-token minting had
no rate limit at all, and PKCE was skipped when code_verifier was absent
rather than refused. An MCP session resumed with a token whose scopes had
since been reduced kept the capability set it was created with, and the
session cap was checked before the async registration that increments it.

Plugin frame containment was lexical, so a symlink out of the directory was
never noticed, and instance config accepted keys the manifest never
declared.
2026-08-23 11:59:12 +02:00
Maurice 4bfb277423 fix(journey,files): close the cross-trip and role holes on the sharing surface
Files: two :id handlers took the file id from the URL and never resolved it
against :tripId. Membership of any trip was enough to read another trip's
file links (with the joined reservation titles) or to delete one. Both now
resolve the file the way every sibling handler already does.

Journey: a contributor with role 'viewer' - blocked from every other write
in the domain - could overwrite or delete the Studio book, and could read
the share token, which is an anonymous credential that outlives their
contributor status. The book writes take canEdit now and the token read
takes the owner check its own create and delete already use.

Also: the share-link MCP tool reset every flag to permissive defaults when
it was called to refresh a link, so a journey deliberately shared without
its gallery or map quietly started publishing both; setShareLink dropped
newest_first entirely; the public routes ignored the Journey addon switch;
and photo uploads committed to storage before the access check, leaking the
objects on the 403 path.

Trips: DELETE answered 403 to someone with no access at all, which turns
sequential ids into an existence oracle. A member who simply lacks
trip_delete still gets 403.
2026-08-23 11:59:12 +02:00
Maurice 515b832fa3 fix(collab): stop serving the text of deleted chat messages
A deleted message keeps its row so the client can draw the placeholder off
the flag, but listMessages still returned the original text - in the list
itself and in the reply preview joined onto whatever quoted it. Both REST
and the MCP resource read through that one method, so blanking it there
covers every surface.

Also cap the link-preview read, and delete the spooled upload when
addNoteFile refuses the request instead of leaving it on disk.
2026-08-23 11:59:12 +02:00
Maurice 97ebe23d3b fix(server): bound every outbound fetch
The rule is a timeout, a size cap and boundary validation on anything that
leaves the process, and several call sites had only the timeout. A link
preview, a maps URL resolve, a wiki fallback and a holiday lookup all read
the whole body into memory, so a caller who picks the URL picks how much
memory the server spends - a few concurrent multi-hundred-megabyte responses
is the whole heap.

Two of them also fetched more than their name suggests. resolve-url fell
through to a full page GET for any host once it found no coordinates, which
makes the server a general-purpose outbound proxy wearing its own IP. The
weather URLs took lat/lng straight from the caller into the query string.

readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and
the plugin installer had each grown separately, so there is one of it now.
Callers that only scan the head of a document (og: tags, a coordinate regex)
keep the truncated bytes and find fewer fields; callers that must have the
whole thing reject it.
2026-08-23 11:59:12 +02:00
Maurice b163c1e05d fix(server): hold DELETE bodies to the same contract as every other write
The body-contract boot gate only looked at POST/PUT/PATCH, so a DELETE that
reads a body slipped past it. Two do; one was already typed, the other read
photo_id straight off an untyped Record. Give it a contract from shared like
everything else, then add DELETE to the gate so the next one cannot repeat it.
2026-08-23 11:59:12 +02:00
Maurice 3f8a7a1ea9 fix(feeds): keep subscribable calendars working under default-deny
The ICS feed routes are documented as public - the secret token in the URL
is the credential - but nothing in the code said so, and the guard this
release makes global is default-deny. Every calendar client subscribed to
/api/feed/trip/<token>.ics would have started getting 401 on upgrade, and
a calendar app that stops fetching just goes quiet.

The e2e suite asserted anonymous access the whole time. It booted
[DatabaseModule, RealtimeModule, FeedsModule], and the APP_GUARDs live in
AppModule, so it was asserting against an app that had no opinion on
anonymity. It boots the real guard stack now, and fails without the fix.

The boot ratchet could not have caught this either: it only looked at routes
carrying @Public(), and GlobalAuthGuard stands down for ANY declared chain -
so a chain of guards that refuse a caller but never resolve one answers
strangers, invisibly. Split the inventory in two and write that half down as
well. It turns up two routes nobody had listed: the passkey login ceremony,
which genuinely runs before a session exists.
2026-08-23 11:59:12 +02:00
Maurice d91eda690a fix(reservations): keep every id in a booking inside its own trip
reservation_edit on the trip in the URL says the caller may write here. It
says nothing about the ids they put in the body, and the write contracts are
open records, so day_id, place_id, assignment_id and accommodation_id all
arrived unchecked. A booking created in one trip could carry another trip's
accommodation_id, and deleting it ran the cascade against that foreign row.

The MCP tools have refused foreign ids since they were written. Put the same
check where every writer reaches it, and scope the two day_accommodations
statements to the trip so a row stored before this still cannot be followed
out of it.
2026-08-23 11:59:12 +02:00
Maurice 39c8a22352 fix(offline): replay queued writes an interrupted flush left behind
A flush marks each row 'syncing' before it sends, and only ever picks up
rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA
does exactly this - and the row stays 'syncing' forever: it keeps counting
towards the pending badge, but nothing ever retries it, fails it or shows
it as a conflict. Anything queued behind it that depends on it later fails
with 'unresolved temp id'.

Stamp the row when it goes in flight and reclaim it on the next flush. The
axios instance times out at 8s, so a minute is well past anything that
could still be running, and the mutation id doubles as the idempotency key,
so replaying a write the server already applied is answered from its cache.
2026-08-23 11:59:12 +02:00
jubnl e91ad69c83 fix(atlas): visual empty stats state 2026-08-22 18:04:38 +02:00
Maurice e9090fa33c fix(notices): confine the 4.0.0 release modal to the 4.0.x line
Without an upper bound it would greet somebody who jumped 3.x straight to
4.2 with a summary of a release they skipped, and it would run alongside
the next release's own notice.

maxVersion is exclusive, so 4.1.0 covers every 4.0.x patch. Pinned in the
registry test, together with the handover from the thank-you notice: at no
version may both be active.
2026-08-22 15:48:13 +02:00
Maurice bb7c484ca4 feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release
needs more room than that: what shipped, and why it took what it took.

Adds a `release` block to the notice contract. A notice carrying it renders
through ReleaseNoticeModal instead of the generic body — the release on the
left, the note from the maintainer on the right, both halves driven entirely
by translation keys so the next release only edits the registry entry.

Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and
the design's proportions hold at every breakpoint.

The thank-you notice gets maxVersion 4.0.0: from there on the release modal
carries the same thank-you and the same two support links, and installs still
on 3.x keep the old one.

All 23 locales.
2026-08-22 15:48:13 +02:00
jubnl 76c8c349b1 Fold nest mcp (#2017)
* refactor(server): fold @trek/nest-mcp into server/src/nest-mcp

* docs(server): point nest-mcp references at server/src/nest-mcp

* remove tracked md file
2026-08-22 12:55:24 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice Böe 942e1932c9 fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking'
the reservation type uses everywhere else. The shared trip page prints the
stored category verbatim, so a share link would have shown "parkings" in every
language.

The category also had no relationship to the booking type of the same name:
RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense
to Transport, so the new bucket sat empty next to the costs that belong in it
while a manually typed "parking" resolved into it. Both paths agree now.
2026-08-21 14:28:53 +02:00
MikeDabrowski 999236f944 feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
  ("Parking"), alongside the existing set (Accommodation, Food & drink,
  Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
  Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
  lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
  parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
2026-08-21 14:28:53 +02:00
Maurice 875f87b885 fix(planner): open the booking a place is linked to
The place inspector summarises the booking attached to the day assignment and
then went nowhere: the strip is a bare div with no handler, no role and no
tabIndex, and PlaceInspector was never handed an editor callback in the first
place. Reached from a map marker, that strip is the only route a restaurant or
tour booking has — only transports with two positioned endpoints get map pins of
their own — so its fields and its attachments were unreachable.

The identical lookup in the day sidebar has always carried a pencil that picks
the editor by type. The inspector now takes the same onEditTransport /
onEditReservation pair and does the same: a ferry, a flight, a train opens the
transport form, everything else the reservation modal. That split matters —
ReservationModal has no transport entry in its type list, so a ferry sent
through it would have its type re-picked on save.

Each handler arrives as undefined when the user lacks the matching right, which
is what keeps the strip a read-only summary instead of turning it into a button
that does nothing. Gating inside the handler would have re-shipped the reported
symptom with a label claiming otherwise.

The phone place sheet never rendered the strip at all, so the same booking was
just as unreachable there, only invisibly. It shows it now and opens the same
two editors, closing itself first — it hangs off the place selection rather than
the sheet stack, so closeSheet() would leave the editor stacked on top of it.

Closes #2012
2026-08-21 14:19:28 +02:00
Maurice a23a81f35b fix(studio): the spread import points inward too
Missed in the sweep: "Import spread" opens a JSON file picker and wore the
outgoing arrow like the rest of them.

Refs #2006
2026-08-21 13:27:27 +02:00
Maurice 331954c1b0 feat(mobile): drag to reorder the day plan, and reach the day sheet from any day
Reordering a day on the phone meant two 15px arrows, while the same list on the
web takes a drag. It takes one here too now, via the long-press bridge tablets
already use (#1616): it watches only draggable rows inside an opted-in
container, waits out a press before claiming the gesture — so a swipe stays a
scroll — and replays real drag events, which is why the rows only needed the
receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps
and map pans are untouched and the polyfill retired in #1432 stays retired. The
arrows stay: they are the accessible path.

Both paths go through one mover, so the chronology guard and the undo entry are
the same either way.

The card header was also the only route into the day sheet, and it rendered only
when the day happened to have an accommodation or a weather reading — with the
accommodation chip as the sole tap target. A day with neither had no way in.
A day pill now leads that row unconditionally, the weather reading is a target
too, and the active day chip carries a chevron so the second tap that opens the
sheet is visible in map view as well.

Closes #1997
Closes #2004
2026-08-21 13:27:27 +02:00
Maurice 2d6c8c5157 fix(mobile): open the stay when you tap the stay
Tapping the accommodation in the day sheet opened the place it is pinned to, so
check-in, check-out and the confirmation code had no way in at all. The sheet
that edits them has taken an accId since the mobile rewrite; nothing ever passed
one.

The row is a real button now and opens that editor, with the place kept one tap
to its right for anyone who wants it. Viewers without day_edit keep the place as
their target, since the editor is no use to them. The linked hotel booking below
opens too — the day's booking list filters hotels out, so that strip was the only
place it showed and it did nothing.

Closes #2001
2026-08-21 13:27:27 +02:00
Maurice da92581a16 fix(planner): let a route choice survive the map, and keep new items on their day
Two things the mobile planner forgot.

The day sheet's route toggle only ever lived in component state, and entering
the map — or tapping a day while in it — forced it back on. Turning the route
off therefore did nothing you could see and had reset itself by the time you
looked again. The choice is now remembered per trip; the map still switches it
on by default, but only while the user has never said otherwise.

Adding a place or a booking from inside a day did not connect it to that day.
The place dropped into the unplanned pool, which on mobile is a different screen
— it looks like the save was lost — and the booking opened with no date at all.
Both entry points now carry the day they were opened from, including a
long-press on the mobile map, where the chip rail names a day at all times. The
desktop map has no such context and passes nothing, so its pool behaviour stands.

While here: the import button on the bookings and transports headers only
appeared when kitinerary was installed, though the server also accepts an import
when the LLM parser is configured (#2007).

Closes #2003
Closes #1998
2026-08-21 13:27:27 +02:00
Maurice 8b88986ca8 fix(bookings): make the reservation import findable on the desktop panel
The import is not missing from the desktop — it sits at the far right of the
bookings toolbar, next to the accent "Manual Booking", above an empty page with
nothing but the mascot on it. On a fresh trip that is exactly where nobody
looks, which is how it reads as absent.

The empty state now offers the ways in that apply: add manually, import from a
file, and AirTrail where it is connected. EmptyState grows an optional action
slot for it, so every other caller is untouched.

Closes #2007
2026-08-21 13:27:27 +02:00
Maurice 9094f05604 fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at
the first activity on any arrival day and stopped at the last one on any
departure day. The two bookend rules ask whether you can be proven to have been
at the hotel already — but they read a missing check-in time as proof against,
and the hotel picker leaves that field blank by default. So the loop hung open
at one end on every boundary day, and at both ends for a stay booked on a single
day.

With no time recorded there is no bar to clear, so the leg is drawn. Where a
time *is* set the existing rules are untouched: a stop before check-in still
draws nothing (#1465), and a transport arrival is still not a hotel departure
(#1321).

Closes #2009
2026-08-21 13:27:27 +02:00
Maurice 5e78069223 fix(journey): saving a suggestion turns it into an entry
A trip visit shows up in the journey as a suggestion, and opening one puts you
in the entry editor. Pressing Save there only promoted it when a story, a photo
or a linked photo had been added — a title, a mood, the weather or a set of tags
all left it a suggestion, and so did saving it untouched. From the outside that
looks like the save did nothing.

An explicit Save is the user saying this is their entry now. handleSave only
ever runs from the Save button, so there is no autosave to misread as intent.

Closes #2008
2026-08-21 13:27:27 +02:00
Maurice df139a2d96 fix(ui): point import controls inward and give Google Maps its own mark
Every import button in the client wore an outgoing arrow, so the control that
pulls a booking or a place list in looked like it was about to send something
out. The places-import sheet managed to contradict itself inside one screen:
Download in the header, Upload on the row below it, Upload again on the file
step. Imports now take Download, or FileDown where the button opens a file
picker; genuine uploads and the desktop drop zones keep Upload.

The mobile places-import entry point wore an ellipsis, which promises a menu.

The "open in Google Maps" buttons drew the four-segment Google *search* G,
flattened to one colour — a glyph that reads as "run a search". They get a map
pin instead, factored into one component so the desktop and mobile copies stop
drifting apart.

Closes #2005
Closes #2006
2026-08-21 13:27:27 +02:00
Maurice ec3017f9cc fix(ui): keep dropdown panels anchored to their trigger
The three fixed-position popovers computed top/left inline while rendering,
which reads the trigger's rect exactly once. Scroll a sheet afterwards and the
trigger walks off while the panel stays nailed to the viewport; on iOS the
keyboard slides up over a panel that was placed before it existed.

useAnchoredPosition measures in a layout effect and re-measures on scroll
(capture phase, so the sheets' own scroll containers count), on resize and on
visualViewport changes. It also reports how much room is actually left, so a
list opened next to the keyboard shrinks instead of hiding underneath it.

CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no
generic box can express, so it takes the bare re-measure signal instead.

Closes #1999
Closes #2000
2026-08-21 13:27:27 +02:00
Maurice e753603ec8 test(db): re-point the trailing-migration guard at the new last slot
The guard proves that a migration appended to the end still runs on an
install that is one version behind, by undoing what the last one did and
replaying it. Its own comment asks whoever appends the next migration to
re-point the undo, which is what this does: the trailing slot now clears the
GB rows out of place_regions rather than creating journey_books.

The Spanish row alongside it is there to catch the opposite mistake — a
migration that clears more than its own country.
2026-08-20 18:40:26 +02:00
Maurice 6a5f067663 fix(atlas): give Great Britain its counties and boroughs back
Zooming into England showed nothing below "England" itself, so boroughs
marked as visited had no polygon to light up. It stopped working in 3.1.0,
when the runtime Natural Earth fetch was replaced by the bundled geoBoundaries
assets: Natural Earth carried 232 GB regions including the London boroughs,
and geoBoundaries calls the four constituent countries ADM1. The builder asks
every country for ADM1, so GB shipped as four features.

GB now ships at ADM2 — 216 counties and unitary authorities, which is the
granularity every other country gets from its ADM1 and the granularity that
was there before. That is a deliberate one-country list: ADM2 everywhere
would multiply the bundle for no gain, since ADM1 already is the level people
think in for most countries.

Two things had to be said to data that already exists. Regions marked before
3.1.0 hold the old Natural Earth codes, and are reconciled by name within the
country, the way the 3.1.1 step already does for everywhere else; a row that
still cannot be matched is left alone rather than destroyed. And place_regions
is full of GB-ENG rows that were correct under the old bundle — nothing
re-derives a row that is already cached, so without clearing them every
English place would keep reporting "England" forever.

The bundle guard that would have caught this in 3.1.0 is now in place: four
features for a whole country is not a region layer.

Closes #1974
2026-08-20 18:40:26 +02:00
Maurice 0ce82b811b fix(atlas): count only the flights a person is actually on
4.0 lets a booking name the people it is for (#1517), and the travel figures
never read that. They scoped reservations by trip membership, so on a shared
trip everyone was credited with everyone's flights: two people meeting in New
York, one from Rome and one from Mexico City, each got both departures, and
each had "visited" the other's country.

The rule now asks whether the reservation belongs to the person asking, in
one fragment used by all three queries that derive figures from reservations
— flown distance, the passport card's countries, and Atlas's own endpoint
scan — so there is one answer to that question rather than three.

Its second half is the important one: a booking with nobody named on it
counts for everyone on the trip. Every reservation made before 4.0 looks
exactly like that, and a plain join would have taken the flown distance of
every upgrading install to zero. With no assignments anywhere the numbers are
identical to before.

Where someone HAS assigned travelers, their figures will go down after this,
because they were counting journeys they did not make. That is the point, but
it is worth a release note.

Places are untouched: they have no per-traveler concept, and this is only
about numbers derived from bookings.

Closes #1966
2026-08-20 18:40:21 +02:00
Maurice 9e13791239 fix(booking-import): geocode transport endpoints on the path clients use
An imported train, bus or rental car showed its pick-up and drop-off in the
review step and then had them gone from the saved booking: no pins, no route,
no error. reservation_endpoints.lat and lng are NOT NULL, so saveEndpoints
drops an endpoint without coordinates, and kitinerary and the LLM name
stations, stops, terminals and rental desks without geo-locating them. Only
airports arrive with coordinates, from the mapper's own table.

The lookup that was meant to fill them in existed — in confirm(), which no
client calls. Both the desktop planner and mobile go preview, review form,
ordinary save, so it never ran.

It runs in preview() now, through a helper confirm() shares, on the throttled
background lane whose own docstring names booking-import as its intended
caller. The query ladder matches the venue lookup beside it: the endpoint plus
the booking's location first, then the address, then the bare name — name-only
is exactly what fails for a desk label like "Curbside Pickup Counter 7", so it
goes last. Answers are cached per preview, since a multi-leg train repeats its
stations and the lane allows about one request a second.

An endpoint that still cannot be placed stays on the item rather than being
filtered, so the review form can show and edit it, and the preview says which
name it could not locate. That warning needed the widget to change too: it
rendered warnings only when a job found nothing at all, which is precisely
the case this is not.

Reported with the full chain, file by file.

Closes #1969
2026-08-20 18:22:29 +02:00
Maurice f574e75dbe fix(pdf): print a day in the order the plan shows it
The export ordered each day by itself, on day_plan_position — one global
number per booking, seeded automatically from the place count of whichever
day happened to render first. On any other day of a span it therefore pointed
at the wrong slot, and where it was still null the export fell back to the
order the API returned rows in, which is reservation_time ASC with nulls
first.

That is why the same trip printed correctly some of the time and swapped two
bookings the rest of it, and why the reporter's workaround worked: adding a
place and removing it again reseeded the position from the day they were
looking at.

The plan has never used that column. It picks transports per day, gives each
one the time that day shows, inserts them among the places and re-sorts the
whole day by the clock — and none of that was reachable from here, because
this file had its own copy of the ordering with subtly different rules.

So the copy is gone and the export goes through utils/dayMerge, the same
functions the plan uses. Two differences fall out of that, both of them the
plan's existing behaviour: a booking attached to a place is no longer printed
twice, and a multi-leg journey prints one row per leg at each leg's own time
rather than one row carrying every leg as a subtitle. The per-segment booking
code #1943 added is read back from the booking, since the leg object does not
carry it.

Closes #1978
2026-08-20 18:08:06 +02:00
Maurice f76ee87336 fix(nav): stop the tab pill from running under the logo and the user menu
The centred tab menu was absolutely positioned, so it had no relationship to
what sat beside it. Its width grows with every enabled addon and every page
plugin, and once it outgrew the free space in the middle of the bar it simply
ran underneath the logo on one side and the bell and user menu on the other.

The only adaptation was a hard-coded 1024px breakpoint that drops the tab
labels — a constant chosen when two or three addons was the whole story, and
one that cannot know a plugin has added anything.

So the bar is three columns in the flow now: the brand block and the action
cluster carry equal weight either side, and the pill sits between them. That
keeps it centred on the bar as before, and makes overlap something that
cannot happen rather than something a breakpoint has to predict. With
min-w-0 the pill gives way and scrolls when even it runs out of room,
instead of pushing the actions off the edge.

Measuring the pill against the free space and folding the overflow into a
menu was the other option. It buys a tidier look at a narrow width and costs
a ResizeObserver on every desktop page, a cache to keep the measurement from
oscillating against the mode it chose, and a new string in 23 locales. Not
for a fix to overlapping.

Closes #1983
2026-08-20 18:08:00 +02:00
Maurice bb0d65bb19 fix(map): read the last fitBounds call without Array.at
The client's tsconfig targets a lib without Array.prototype.at, so the two
new assertions typechecked locally against the test runner but not against
tsc. Index arithmetic instead.
2026-08-20 18:07:54 +02:00
Maurice 7aead0b713 fix(map): stop the day fit from framing everything and then drifting off it
Selecting a day compensated for the day panel twice: the fit reserves the
panel's height in its bottom padding, and then 300ms later the camera was
nudged again with panBy([0, 150]). Both came from the same change, and
together they moved the view 150px further than intended.

Wide days never showed it. A day that runs north to south does: the fit is
height-bound there, so the northernmost stop sits exactly on the top padding
line, and the nudge then pushes it off the canvas. What the user sees is the
map framing the whole day correctly and then sliding upwards, leaving the
first marker outside the view.

The padding alone is right, and it is what MapViewGL has always done with the
same numbers, so this also brings the two renderers back into agreement.

Closes #1982
2026-08-20 18:07:54 +02:00
Maurice d0cc4e2f00 fix(planner): give the day's optimize button room by dropping its label
Every button in the day's route-tools row hands the day somewhere: show the
route, open it in Google Maps, open it in CoMaps, reorder it. Two were
already icon-only. Optimize carried a label on flex: 1 with padding '6px 0',
which is no horizontal padding at all — survivable until CoMaps added a fifth
button, at which point the text sat hard against both edges of its own
button.

Icon-only now, matching the two beside it. The label stays as the accessible
name and the tooltip, so nothing about reaching the action changes, and the
row has room for whatever gets added to it next.

Closes #1981
2026-08-20 17:51:33 +02:00
Maurice a5e468829c fix(llm-parse): stop discarding an extraction Anthropic sent as a string
With the Anthropic provider, an import sometimes reported that no
reservations were found even though the document held one and the model had
extracted it. Nothing was logged, because from the code's point of view
nothing failed: the value simply was not an array, and the check that only
accepted arrays returned an empty list. What reached the user was
indistinguishable from a document with no booking in it.

The tool declares `reservations` as an array and the call forces the tool, so
it usually is one. Sometimes the model serialises the same content as a
JSON-encoded string instead — either a stringified array, or stringified and
wrapped in `{ reservations: ... }` again. Because that is a serialisation
choice made per call, the same file imported fine on one attempt and came
back empty on the next.

Normalising is now one function next to the lenient JSON parser, and the
OpenAI-compatible client uses it too — it had grown its own version of the
object-unwrap half, and two copies of this rule would drift.

It unwraps a string once and no further. A value that is still not a list
after that is not a list, and reaching deeper would start turning prose into
bookings.

Reported with the exact file and lines, and with both observed shapes.

Closes #1968
2026-08-20 17:51:29 +02:00
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00
Maurice 68bf5316ad fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they
carry the reservation; the accommodation cascade sends them empty, as a
"something changed, go and look" ping that the event contract allows for.

The applier read the id off both. With no reservations loaded yet the
existence check never ran its callback, so the cast slipped through and the
store became [undefined]. From there every render that walked the list threw
"Cannot read properties of undefined (reading 'endpoints')" into the route
error boundary, and since the store is rebuilt from the same events, the trip
stayed unreachable in that browser. The reporter could only get out of it by
clearing localStorage.

Both appliers now decline an event with nothing on it, and the Dexie writer
does too rather than leaning on a swallowed catch. The predicate that walks
the list is total as well: it guarded the property but not the element, and
it is handed straight to Array.filter over a list it does not own.

Declining the empty ping would have left a hotel booked by one member
invisible to the others until they reloaded, so the ping is honoured the way
day:reordered already honours its own — fetch the authoritative list.

The server keeps sending what it sends. Making the cascade carry the full
reservation would mean moving the REST, MCP and RPC paths together with
their pinned tests, and would still leave the client fragile against the
next emitter that does this.

Closes #1979
2026-08-20 17:37:54 +02:00
Maurice 8f171453d3 fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket
in the trip room. Ticking something off your own list through an assistant
pushed that row to every other member.

The REST and RPC surfaces have scoped these events since #858 — both go
through emitToViewers, which delivers to the owner and the recipients and
nobody else. The MCP tools called safeBroadcast, which took three arguments
and had no way to say who may see the thing that changed.

So safeBroadcast learned that fourth argument, and the packing tools pass
viewersOf(item), which is the same answer the REST route asks for. A shared
item still answers null and still goes to the room in a three-argument call,
so nothing about it changes — including what the hundred-odd broadcast mocks
in the suite record.

The client stops trusting the wire on this too. It used to `put` whatever
arrived, and the offline read hands back every cached row for the trip with
no privacy filter, so a single stray event lodged another member's item in
that browser permanently. It now drops a restricted item that is neither
mine nor shared with me — deleting rather than skipping, so a list that was
already leaked into heals itself the next time an event about that item
comes past.

Closes #1976
2026-08-20 17:30:01 +02:00
Maurice eae8480728 feat(studio): follow-ups from using Studio on a real journey (#1984)
* feat(studio): widen the document, and stop a save from losing it

The schema gains what the elements coming after it need: an icon element, a
badge that can be restyled rather than only placed, a date and a pair of
coordinates on a text binding, and the map's route, pin and road settings.

Widening it turned up two ways a book could fail to come back.

The mood badge writes its code into the document, and the schema said that
code was exactly two characters, from a first draft where it was a country.
The real codes are words, so every save was rejected and the read that
followed parsed to an empty document: seventeen spreads in, nothing out.
Widened to what the badge actually writes, and normalizeBookDocument now
drops the elements it cannot read rather than the whole book, so one bad
element costs a picture frame instead of a year of work.

The other was the body limit. The parser allowed 100kb for everything, and a
book with a routed map measures past that, so the save came back 413 and the
editor sat on "not saved" with no way forward. Book writes get their own
parser at 8mb; everything else keeps the limit it had.

* feat(studio): a date, a pair of coordinates and an icon library

Three things that had to be drawn by hand before.

A journal entry knows when it happened and where, and neither could be put
on a page: you read the date off the entry and typed it into a text box,
where it then stayed while the entry moved. Both are chips now, next to the
title and the story, and both follow their entry the way the others do.
The formatting lives in one place so the chip, the template and the resolver
cannot drift by a character — coordinates in degrees or decimals, the date
in the reader's language.

The icons are the whole Lucide set, searchable, placed as a real element
with its own colour and stroke rather than as a character in a font. The
stroke is deliberately not called strokeWidth: three separate places rescale
a field of that name by page width, and an icon whose line thins as the book
grows is not what anyone asked for.

And the badges finally have their settings. A mood or weather badge could be
placed and then only moved: the icon's colour was fixed, the icon could not
be turned off, and there was no way to keep the icon and drop the words. All
three are switches now, with the colour following the badge unless you say
otherwise.

* fix(studio): let a placed entry field follow its journal entry again

The inspector says a bound field follows the journal entry and that editing
it here breaks the link. The first half was not true: nothing ever re-read
the entry, so renaming a title in Journey left the old words on the page for
good, and the same went for every other bound field.

The resolver runs where the journey is loaded and re-reads the fields that
still point at an entry. What it will not do is as important as what it
does. A field somebody has typed over stays typed over, an entry that is
gone leaves its element alone rather than blanking it, and an entry that
answers with nothing never wipes what is already on the page.

Formatted fields — the date, the coordinates — are only re-read when the
format itself changed, which is what keeps this from quietly rewriting
books that already exist: an entry whose date was placed as a heading and
then set in capitals keeps its capitals. Checked against the real
database before it shipped, where the first version overwrote three of them.

* feat(studio): start a new book empty, with six layouts to choose from

A new journey opened onto a book that had already been laid out, which is
the wrong first move: it decides the shape of somebody's book before they
have seen it, and undoing it means deleting pages one at a time. A new book
starts empty now, and the auto layout runs when it is asked to.

The layouts themselves went from two to six, so choosing one is a real
choice: what was there covered a photo-heavy spread and a text-heavy one and
nothing in between.

* fix(studio): hyphenate the export the way the editor does, and set the bleed

Two things the press sees and the editor did not.

A paragraph broke in different places in the PDF than on screen. The print
document is written from scratch and never carried a language, so the
browser hyphenated the editor's German and the export's nothing-in-
particular. It takes the page's own language now. The wait for the sheet to
be ready also covers the images inside SVG, with a ceiling, so a map that is
slow to draw does not print half-finished.

And the bleed and the safe margin are typeable. Both were baked into every
page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with
no control anywhere. That is fine until a printer asks for 5 and 10, at
which point the book cannot be made here at all: the export carries the wrong
bleed and the crop marks sit in the wrong place, which nobody finds until
the guillotine has run.

* feat(studio): rebuild the inspector

The panel on the right had grown by accretion: every element type had added
its own controls in whatever shape suited it that day, so the same idea —
a colour, a size, a toggle — looked different depending on what you had
selected, and long panels were a wall of unlabelled fields.

It is one set of controls now, used by every element: foldable sections, a
line that holds a label and its control, radio choices, checkboxes, the
app's own toggle, and a number field that holds a draft so typing "500" is
not read as "5", clamped and written back under the cursor. Duplicated
controls were merged rather than deleted; nothing an element could do before
it cannot do now.

The head carries the element's identity and its actions, so what you have
selected is readable without counting fields, and the sections have room to
breathe: what was there was correct and unreadable.

* feat(studio): a route map worth printing

The map in a book was a straight grey line between grey dots on a tile that
was chosen for a screen. At print size those tiles are read at roughly four
times the resolution they were fetched for, which is why it looked soft next
to everything around it.

The imagery comes from sources that hold up on paper: NASA's shaded relief
and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that
matches the printer rather than the screen. Tiles are fetched at twice the
density where the source offers it, and the seam between them scales with
the tile instead of sitting at a fixed fraction of a millimetre, which under
editor zoom fell below one device pixel and showed as white threads.

The route is drawn rather than plotted. A leg that was never recorded bows,
by an amount that grows with its length and turns away from the pole, and it
dashes, because an inferred line should not claim to be a track — a recorded
track never bows. Both strokes are laid down in two passes, casing then core,
so the line stays legible over dark sea and bright desert alike. Legs can
follow roads instead, routed leg by leg with the geometry thinned before it
is stored, which is the setting a road trip wants and the one a flight
cannot have.

Stops carry their own first photograph, cut to a circle by a stencil that
exists — the first one was written into a defs block that only appears on a
map clipped to a coastline, so on an ordinary map it clipped nothing and the
photographs sat square on the page. A stop with no photograph of its own is
numbered rather than handed one from elsewhere in the journey: a marker at
one place showing a picture taken at another is a caption that lies.

Whatever the frame, the whole route is inside it. The window takes the
frame's aspect, so the fit and the imagery agree instead of one containing
what the other covers, and a map dragged narrow no longer cuts off its end.
Each linked trip can carry its own map, and a map notices a photograph added
to a stop afterwards rather than having to be placed again.

* i18n(journey): the new Studio strings, in all 23 locales

* test(journey): pin the coverage the map work brought with it

The journey domain measures higher than its floor after this round, so the
floor follows it up. Only journey moved; the generator's suggestion would
have taken memories' functions down a point, and a ratchet that can fall is
not a ratchet.

* test(studio): cover the thinning that keeps a road route saveable

The one path in roadRoute nothing exercised, and the one that matters most:
a router answers a long drive with thousands of points at a precision no
press can resolve, and all of it goes into the document. Keeping a leg whole
is what pushed a real book past the body limit.

Both ends have to survive so the line still meets its stops, and the spacing
has to stay even, because cutting a run out of the middle would straighten
exactly the bends somebody wanted to see.
2026-08-20 15:43:27 +02:00
Maurice 736599a89b feat(journey): TREK Studio — a photo book designer for Journey (#1973)
* fix(photos): serve local photos root-relative so a dotted install path works

res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest
ExpressAdapter and answers a spurious 404 even though the file is there — the
same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74
already work around with an explicit { root } + basename. The photo resolver
never got that treatment, so every thumbnail and original 404s on any install
whose uploads path contains a dot segment (~/.local/share/trek and friends).
The public journey asset route had it too, which took shared journeys with it.

The guards above each call already confine the path to the uploads tree, so the
root form is no weaker than what it replaces.

* feat(shared): add the book document contract for TREK Studio

Millimetres throughout, not pixels: a pixel means nothing to a print shop and
changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and
72pt with a fixed ratio, so the editor can render the document at a scale and the
print renderer at 1:1 and land on the same page.

Paint order is the array order rather than a zIndex field that can disagree with
itself, text elements may stay bound to the journal entry they came from, and a
spread can park content a layout has no room for instead of dropping it.

* feat(journey): open TREK Studio over the journey

Studio is a child route of /journey/:id and portals itself above it, so the
journey stays mounted and shows through the panel's 16px margin — it reads as
something opened on top of the journey rather than a place you navigated to. The
URL exists for reload, the back button and shared links; the user never sees it.

Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95
resets button font, colour, background and padding at a specificity a bare .st-x
loses to, so an unscoped rule silently does not apply to a button.

Escape clears the selection and only leaves on a second press. In a dialog
Escape means go away; in an editor that reflex would cost you the page.

* feat(journey): build, edit and arrange the book in Studio

The auto mode turns a journey into a book once and then gets out of the way: what
it produces is an ordinary document, every element as editable as one placed by
hand. Templates work the same way — applying one pours the spread's existing
photos and text into the new arrangement and parks whatever will not fit, so
trying a text-only layout does not cost you the pictures.

Moving and resizing are hand-rolled on pointer events. The drag-and-drop
libraries in this space move items between lists and have no notion of a free
frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot
control and no position until the drop. It is used for exactly the one shape it
suits — dragging a photo out of the content panel onto a frame.

Selection chrome carries two rings, dark inside light. A single line vanishes
against a photograph of its own value, and over a picture that is bright on one
side and dark on the other no measurement of the background can be right.

The sheet renders in DOM rather than on a canvas so the same component tree can
be what the print renderer runs — one renderer, nothing to drift against. Edit
affordances live in a layer above it and can never reach the page.

* i18n(journey): studio strings across all 23 locales

The empty-frame prompt breaks per language rather than copying the English line
break onto everything — forced onto Japanese or Arabic it strands a fragment on
the second line.

* refactor(journey): keep SpreadView to components only

FONT_STACKS and photoSrc move to their own module: a file that exports both
components and constants breaks Fast Refresh, and this is a file you edit
constantly while designing a page. Types come from the journey store instead of
any, and the count beside the content tabs is a badge rather than competing with
the label for the same weight.

* feat(journey): more shapes, decoration and a content search in Studio

Six shapes now instead of three — rounded rectangle, triangle and an outline-only
box join the rest, plus a quotation mark and an outlined circle under decoration.
A triangle is a clip path rather than the old CSS border trick: that hack cannot
take a fill gradient or a stroke and its size does not follow the frame the way
everything else does. A stroke sits inside the frame, or the drawn shape would be
wider than the box the editor snapped and the print would not match it.

The content panel gets a search over photos and entries. A photo matches on its
own caption and on the entry it hangs from — most photos carry no words at all,
so matching captions alone would look broken on exactly the journeys that need
it. Plain filtering over what is already in memory; a journey holds tens or
hundreds of items, not thousands.

Dates in the panel and in the book now follow the app's language through the
house formatDate rather than the browser's locale, and the entry's date and place
are two badges instead of one dot-joined string the eye has to parse.

* @
feat(journey): a shape library, picture frames and photo grids for Studio

Studio had six shapes, two frames and no grids. This is the library that
makes it usable for a book rather than for a diagram.

Shapes are SVG paths now, not clip-paths. That was forced rather than
chosen: a clipped div cannot take a stroke — the clip cuts the border in
half lengthwise — and a heart is not a box with a radius. Rectangles and
ellipses keep their old div rendering, since a box genuinely is better as
a box and every document written so far contains those two.

Two decisions in shapes.ts are worth stating.

The path *points* are scaled, not the coordinate system. Fitting a path
with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm
outline on a frame twice as wide as it is tall comes out 0.7mm on the
sides and 0.35mm top and bottom. Printed next to a straight rule at
300dpi, you see it. Hence the deliberately boring path subset: absolute
M/L/C/Q/Z, numbers alternating x and y, no arcs.

And every shape fills its 0..100 box exactly, enforced by normalise() at
module load rather than asked for in a comment. The editor draws its
selection outline, its resize handles and every snap target from the
element frame, so a shape occupying 86% of its own frame cannot be
aligned against anything you can see. It costs a regularity nobody can
spot — a hexagon in a square frame still reads as a hexagon.

Frames are the same list: a picture frame is a shape with a photograph
inside it, and keeping two would mean a heart that exists as one but not
the other. Photos gain a mask and a decoration, kept independent, plus
the Polaroid, film and tape treatments that are not expressible as either.

Grids take their gutter out of the cells rather than adding it between
them, so a grid asked to fill the safe area fills it instead of
overhanging by one gutter.
@

* @
feat(journey): derive what a journey adds up to

GET /api/journeys/:id/stats — distance, days, steps, photos, places, the
furthest point, the countries in visit order, and the route itself. Read
only and derived: nothing here is stored, it is what the trips added to
the journey come to.

The route comes from the journey entries where they carry coordinates,
and falls back to the places on the linked trips where they do not —
which is the common case for a journey assembled from trips nobody has
written up yet. The two are never mixed: an entry written about a place
that is also on the trip would be two stops at one location, and the
distance would count that leg twice.

Countries read place_regions first, which Atlas already fills, and fall
through to getCountryFromCoords for the rest. That import is a plain
function from a module built without DI or Nest edges, so it costs the
journey domain no coupling to Atlas.

Distance is great-circle metres, not driving distance: there is no
routing engine here, consecutive stops are not necessarily joined by a
road, and a book that prints a road distance it never measured is worse
than one that says "as the crow flies". Metres because kilometres and
miles are a display choice.

A hotel across three nights is one place with three day assignments, so
the place query aggregates back down to one row at its earliest day —
otherwise the route would visit the hotel three times.

Two test fixes came out of writing this. journey.controller.test.ts still
asserted an absolute path from res.sendFile, which the photo fix in this
branch had already changed to the basename-plus-root pair; and
linkTripToJourney in the factories wrote to linked_at, a column that does
not exist — it had no callers, so it had never once run.
@

* @
feat(journey): travel elements — the route, the figures, the countries

A photo book of a trip is not an album: the trip carries facts, and those
facts make pages photographs cannot. Four new elements — a vector route
map, a figures panel, a country page and a set of small marks — plus the
mood, weather and pros/cons an entry already records.

Every one of them carries its own values, resolved when the element is
placed and written into the document. The print renderer is headless
Chromium loading a page; one that needs an authenticated fetch to know
what it says will print blank the day a token expires. It also makes a
book a record of a trip as it was, rather than something that quietly
rewrites last year distance when a stop is added. travelRefresh compares
what is stored against the journey and offers an update, so bringing an
element up to date is a choice.

The map is vector, from the coordinates, over silhouettes simplified
offline from the admin-0 boundaries the server already bundles for Atlas
— no licence question, nothing to fetch at render time, sharp at any
print size. It paints no background: a map is geography sitting on the
paper, not a window cut through it, so it can be laid over a photograph.

Flags are drawn rather than typed. A regional-indicator pair is the
obvious answer and works everywhere except Chrome on Windows, which has
no flag glyphs and renders the two letters instead — "DE" in a printed
book is not a flag. Thirty-three flags are exact constructions; anything
with an emblem or unequal bands is deliberately absent and falls back to
the country outline, because a wrong flag is worse than no flag.

Panel tiles render the real element small, through the page own
renderer. These differ from each other by what they say rather than by
their shape: four map styles are four maps, and no icon can tell you
which is which.
@

* @
feat(journey): manage spreads from the pages rail

Add, insert between two spreads, duplicate, reorder and delete. None of
it existed — a book was whatever the auto layout produced.

The cover and the back cover are fixed points: a book has exactly one of
each, they are single pages, and no sequence of clicks should be able to
produce a book with two covers or none. Every operation asks one
predicate whether a spread is inner, rather than each caller testing role
for itself, and a spread inserted after the back cover lands before it.

Duplicating re-ids every element it copies. Two elements sharing an id
would make selection ambiguous and every lookup that follows it wrong,
delete included.

Also: a locked element can be selected again. Locking used to switch its
hit target off entirely, which was a dead end — it could not be picked,
so the inspector never showed it, so the unlock button was unreachable
and the only way back was undo. Locked now means what it says: it cannot
be moved, and its resize handles are hidden so the editor does not offer
a drag it will refuse.
@

* @
feat(journey): let the auto layout use the travel elements

The book now opens on a summary spread — the route drawn on the left, the
figures on the right — and, for a journey that crossed a border, a country
page after it. Both are omitted rather than left empty when there is
nothing to draw: a spread holding a blank frame over the words "0 KM" is
worse than a shorter book, and every page in a printed one costs money.

Only the figures the journey actually has make it onto the page. A zero
is not a fact worth a tile, it is a hole in a composition.

Each entry gains a short accent rule above its heading — one drawn
element, and the cheapest thing here that makes a spread read as designed
rather than as text placed on a page. An entry with no photographs, which
is the one spread with a blank left page, sets its date as a numeral
there: a gap in the book becomes a pause in it.

The back cover closed with `${places} Orte` — German, hardcoded, in a
book that follows the app language everywhere else. It is a figures
element now, so it takes its labels from the same translations as the
rest of Studio.

The layout waits for the figures rather than racing them. Laying out
first would give a journey a summary spread or not depending on which
promise resolved first, and Studio already shows its loading state until
the document exists, so the wait costs nothing.
@

* @
feat(journey): the Studio wordmark replaces the book-name field

The field asked for a second name for something already named after the
journey — a box whose only real use was being left alone. The bar says
what this is instead: the lockup, inline so it takes the theme ink
through currentColor rather than needing a light-on-dark copy, and a beta
mark beside it.

Along with the strings for everything in this branch, across all 23
locales, and the two keys the name field used, removed rather than left
as orphans nobody can tell from live ones.

journey.studio.frames was "Picture frames" — too long for a tab sharing a
row with two others, and untranslated in 21 of the 23 locales, which
showed the English through. It is one word now, in every language.
@

* @
feat(journey): a free trim size, page numbers, and auto layout as a button

Three from the list the PR left open.

**Custom trim.** The five presets are what vendors actually bind, which
covers almost everyone — "almost" being the problem. A local printer with
a 24x18 press, or a book meant for a particular shelf, could not be made
here at all. Two fields under the format list, and typing in them *is*
picking custom: a book that is 210 by 240 is not square-21 with a typo,
so the preset follows rather than needing to be chosen first. Switching
to custom keeps the page it is on — it adjusts the format you have.

**Page numbers.** Set on the document and drawn by the renderer, which is
the only place that knows what number a page is. Stamping them with
pdf-lib afterwards would print numbers the editor never showed, and a book
you cannot proofread on screen is not much of an editor. They are also the
one thing on a page that is not an element: an element holding "14" would
still say 14 after its spread was moved. `startAt` exists because the
first inner spread is rarely page 1 once bound, and outer/inner is a real
distinction on a spread — getting it backwards puts both numbers in the
fold.

The controls live in the inspector empty state, which was one line of
hint text. With nothing selected, that panel is where the book own
settings belong.

**Auto layout as a button.** It ran once, on open, which made it the one
thing in Studio you could not ask for; a page that came out wrong had to
be rebuilt by hand. Now it does one spread or the whole book, both as
ordinary commits — the first thing anyone does with a button like this is
press it to see, and the second is want their page back. A single spread
keeps its id so the rail does not scroll, and refuses on anything not
built from an entry rather than replacing it with a blank.
@

* @
feat(journey): cover layouts, and five more for the spreads

The cover had none. The panel said covers are designed by hand, which was
true and unhelpful — it is the page people care most about and the one
they had no starting point for.

A cover is a different problem from a spread and gets its own set rather
than a filter over the existing one: it is a single page, the type is the
subject rather than a caption to a photograph, and a spread layout applied
to it lands half its frames past the edge. Five of them, from full bleed
to type-only, and the panel serves the right set for whichever spread is
open. The cards scale as one page there, and drop the fold line, since
there is no fold.

Five more spread layouts as well — a picture with a narrow column of
story, nine up, a mosaic around one large frame, two pictures with the
words between them, and four staggered so a row of squares stops reading
as a contact sheet.

Applying a layout also stopped mangling a single text. The old rule took
the longest text as the story and looked for a heading among the rest,
which reads a three-text spread correctly and gets a one-text cover
exactly wrong: the 30pt title *is* the longest text, so it was the story,
so a cover layout with a heading slot and no body slot had nowhere to put
it and parked the title. Size decides the heading now; length only sorts
what is left.
@

* @
feat(journey): seven self-hosted book faces, and a picker that shows them

The serif slot was Georgia. Georgia is a system font — on most desktops,
on no Linux container — so the print renderer would have substituted
something else and a book proofread in one typeface would have printed in
another. The existing Journey PDF export has exactly this bug today,
asking for Inter while only Poppins is bundled.

Every family is self-hosted through @fontsource now, all OFL, all loaded
with Studio own chunk so a face cannot arrive after the text has been
laid out and reflow the line endings someone was setting. Two sans, three
serifs from bookish to high-contrast, two display faces for a cover.
Every stack ends in a generic rather than in another named font nobody
shipped.

The three original ids are kept by name, so documents written so far
still parse — they point at bundled faces rather than at whatever the
machine happened to have.

The picker sets each family in itself. A row of chips reading
"Poppins / Lora / Bebas" in the interface font tells you their names and
nothing about the only thing anyone is choosing between.

Weights a family does not ship are disabled rather than offered: asking
for bold on a single-weight display face gets a synthesised one, which
prints as a smeared regular. Picking such a family moves the weight to
the nearest it really has.
@

* @
feat(journey): real map imagery, from the providers TREK already has

The map element drew country outlines and nothing else. That is still the
default and still the right one — nothing fetched, nothing licensed,
sharp at any print size. But an outline of Iceland cannot show which road
you took, and some books want that.

So two more sources, both reading the tile template and Mapbox token an
admin already configured for the planner and Atlas. A second place to
paste a token is a second place for it to be wrong, and a source with
nothing behind it is not offered at all — showing "Mapbox" to someone
without a token produces a grey rectangle and no explanation anywhere.

Both costs are stated rather than hidden: tiles are fetched when the page
renders, and they are raster, cut for one zoom, so printing much larger
than they were fetched goes soft.

Attribution is neither optional nor a setting. The licence requires
credit and a book is a published work — nobody can add a footnote after
it is bound. The credit is matched on the host, so an instance running its
own OSM mirror still credits OpenStreetMap, and an unrecognised source
gets none rather than an invented one. It is written into the document
when the element is placed, so the renderer cannot lose it.

The route is projected onto the tile grid when there is one. The vector
map fits itself to country outlines, and a line placed by that fit sits
next to the road rather than on it — the sort of error that only shows up
once it is printed. Outlines are dropped under imagery, where they would
be a second coastline over the one in the picture.
@

* @
fix(journey): make the Studio type-check again

The map element used tileView and projectOntoTiles without importing them,
useMapSources declared a stricter frame type than any real frame satisfies,
and the mood icon read a colour through an `in` check that types as unknown.
The page fixtures in the studio tests predate page numbers, so they build a
BookPageSetup that the contract no longer accepts — now parsed from the
schema, which cannot drift from it.
@

* @
feat(journey): the book is saved

Studio laid a book out on every open and kept it in the tab. Close the tab
and the afternoon went with it, and the auto layout was not a starting point
but the only point.

The document now lives in journey_books, one row per journey, and autosaves:
quiet for a moment after the last change, or at the latest fifteen seconds
in, so a drag across a spread is one request rather than four hundred. A
stored book wins over a fresh layout on open, and closing the editor writes
whatever is still queued.

Everyone on a journey can edit its book, so the write carries the version it
was made against and the update matches on it in a single statement. A save
that has been overtaken is refused with the current record in the body,
which turns "your work vanished" into a choice between two versions. The
server broadcasts journey:book:saved to the other editors — the version, not
the document — and a client with nothing outstanding takes the new one. A
client with unsaved edits deliberately takes nothing and conflicts on its
next save instead.
@

* @
feat(journey): export the book

The one thing Studio could not do. The button was there and disabled.

A book now goes out as sheets: every inner spread cut down the middle into
leaves in reading order, bleed carried out past the trim on all four sides,
crop marks outside that saying where to cut. The cut is a window rather than
a re-layout, so a photograph crossing the gutter still meets itself once both
halves are printed. Spreads stay whole in the other mode, which is what you
send someone to read.

The browser makes the PDF, from the editor components at 1:1. Considered and
rejected: headless Chromium on the server, and a PDF library drawing the
pages a second time. TREK is self-hosted, and neither Chromium in the image
nor a second renderer to proofread against is worth carrying when the browser
already produces exactly what the editor shows — it is also how the other
exports here work. What the browser will not write is PDF metadata and the
TrimBox entries; the marks carry that instead, as they have since long before
the format existed.

Two things had to give. `SpreadView` clipped at the trim, which is right on
screen and leaves a white hairline down every edge of a bound book, so print
mode lets the page run out and the sheet does the clipping. And the print
document is its own document: it carries the app stylesheets across so the
seven bundled faces are the ones that print, and waits for images and fonts
before it offers to save.
@

* @
fix(journey): make the Studio popovers cover what they cover

The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when
the user has transparency on. That reads as depth over the workbench, where
the rails and the bar use it, and as a mess over the inspector, where this
menu opens: two layers of text through each other, neither readable.

The export dialog was worse — it named --panel and --card, which are not
tokens here, so its background resolved to nothing at all and the whole thing
was see-through.

Both are opaque now. The other glass surfaces are left alone: they float over
the workbench, and that is what the material is for.

The print preview also fits the pane. A spread sheet is over 400 mm across,
so at 1:1 the book ran off the side and had to be read by scrolling sideways.
Screen only — the print stays at full size.
@

* fix(journey): a cover keeps its own size, and a trim size can be typed

Three things the export and the page setup got wrong.

Spread mode made every sheet as wide as the widest so that one @page rule
would cover them all. That put a cover — one page — in the middle of a
spread-sized sheet with white either side, which is not a cover. Each sheet
is now sized to what is on it, and the print CSS carries a named page rule
for the single-page ones.

The trim fields clamped on every keystroke, so typing 500 meant the field saw
5, clamped it to the minimum and wrote that back under the cursor: the number
could not be typed at all, only nudged with the arrows. The field now keeps
what is being typed and tells the document about values it can use. The
arrows are unchanged, and leaving the field still clamps — someone who typed
5 and clicked away meant the smallest book.

And the autosave could loop. The save effect depended on the whole store
object, which is new on every render, so after a conflict — where the local
document is by definition not the saved one — it was a save attempt per
render: a 409, a re-render, another attempt. It depends on the callbacks now,
and a conflict holds the autosave until somebody answers it.

* feat(journey): page numbers pick their own colour

A book has dark pages and light ones, often on the same spread, so one folio
colour is wrong on half of them: the warm grey that reads on paper vanishes
into a full-bleed night photograph, and white vanishes the moment the page is
white. The way out used to be setting it per spread, which the document has
no place for.

The renderer asks what is under the number instead. It knows every element
and where it sits, so it walks them from the top down and takes the first one
that covers the spot: a shape's fill is arithmetic, a photograph gets white
with a hairline of shadow — unknowable without decoding it, and dark more
often than not once printed — and bare page takes the page colour.

A heuristic, deliberately: the alternative is sampling pixels from an image
that may not have loaded, in a renderer that also has to run for print.
Anyone it gets wrong can still pick a colour, and picking one turns it off.

* feat(journey): see who else is in the book, and where they are

Two people editing the same document without seeing each other is how you get
two people editing the same spread. So: a badge in the bar saying who has the
book open, and an arrow with a name on it for each of them.

The coordinates are millimetres on the spread, not pixels on a screen — the
two of you are at different zoom levels on different monitors, and a pixel
means nothing to the other one. They carry a spread index too, so a pointer
on page 40 is not drawn on page 2.

Ephemeral throughout. Nothing is written down, the room is the authorisation
for the pointer path, and a socket that dies takes its arrow with it rather
than leaving it on everyone else's page. Presence is keyed by socket because
two tabs are two pointers; the badge folds them back into one person, because
two tabs are not two people.

The rate limit needed room. Pointers move about ten times a second, three
times what the per-socket limit allows, and that limit is what stops a client
flooding the process — so it is now two: an outer ceiling every frame counts
against before parsing, and the original tight limit on everything a pointer
is not. Malformed JSON still counts against the tight one, which is what the
guard was for.

Throttled to ten frames a second going out and interpolated coming in: sixty
would be five times the traffic for something the eye cannot use, and drawing
ten as they land reads as a stutter rather than a hand.

* fix(journey): keep the zoom bar on screen at every zoom level

Zoomed far enough in, the bar slid off the bottom of the window — and it is
the only way back out, so the editor was stuck at 300%.

The workbench sat in a grid track sized `auto`, which grows to whatever is in
it; `overflow: auto` on the child does not stop that, because a grid item's
minimum size is its content unless told otherwise. So at 300% the box grew to
2400px inside a 660px window and took its absolutely positioned bottom edge
with it. The track is bound now, and the workbench scrolls instead of growing.

The bar also has a z-index, for the same reason it has to stay in view: it
must never be the thing something else covers.

* feat(journey): Studio replaces the old book export

The journey PDF and Studio were two ways to print the same book that disagreed
about what it looks like — and only one of them can be edited. The old export
goes: the two buttons on the desktop already sat next to the Studio one, and
the renderer they called is deleted rather than left as a second answer.

Nothing takes its place on the phone. The book is a Studio document now and
Studio is a desktop editor; a phone-sized PDF of something laid out for print
was the old export's answer to a question nobody was asking.

* feat(journey): lay the book out the way a travel book is laid out

The auto layout built every spread the same way: a picture on the left, words
on the right, and nothing else. It came out looking like a template because it
was one — and on a journey with little in it, like a stack of blank paper.

Five layouts now, chosen by what an entry has and alternating as the book goes
on. A place page for a stop nobody wrote about, with the country pale behind
the name and the coordinates under it. A story page with a small map of the
stop and the picture bleeding off three edges. A panorama across the fold. A
gallery with the hero on one page and a ruled strip on the other. And every
fourth picture spread goes full bleed with the words set over it, because a
book of loud pages has no loud pages.

The marks are what actually make these pages: a flag beside the country, a
date numeral, a chip saying which day this is, a rule along the foot with a
dot showing how far in you are. Each is trivial and together they are the
difference between a page that was designed and one that was filled in. All of
them are elements the panels can make, so what comes out can be taken apart.

The summary spread gained a heading and the furthest point drawn as two dots
and a line — that figure means nothing until it is a distance between two
names. The countries page went dark, because the printed one is: pale outlines
on near-black is the single most recognisable page in these books.

Two things the old layout got wrong are fixed on the way. Entries with nothing
on them share one page instead of taking a spread each, in the order they
happened rather than gathered at the end. And an entry whose photographs have
not arrived gets the frames they will go in, empty, rather than blank paper —
the layout is finished and the pictures are the only thing missing, which is
the right way round.

* feat(journey): build the spreads out of areas, and set the elements in ink

Two things, both about what a page is made of.

The layouts were photographs with captions. A printed travel book is built out
of areas: a page laid solid in ink with pale type on it, a band down the outer
edge, a picture that overruns the panel it sits against, a box ruled around a
block of text. Two more layouts do that now — one with a whole page in ink and
a second photograph crossing the fold onto it, one with an edge band and the
words in a ruled box, pictures at two sizes and one overlapping the hero. Six
layouts on a rotation, so the book stops reading as a template.

None of it needed a new feature. The document paints in array order, so a panel
pushed first is a panel everything else lands on; that is all layering is, and
it needed using rather than adding.

And the travel elements are ink by default rather than the app's orange. A book
is printed and a printed page is ink on paper; a colour that arrives without
being chosen turns every element into the same accent whether the book wants
one or not. Picking one is a click. The dark map is the exception — the route
goes pale there, because ink on a near-black country is a line nobody can see,
and only the default is overridden: a colour somebody chose stays theirs.

* fix(journey): three things the editor got wrong

An empty photo frame rendered on its own, ignoring frameStyle entirely — so a
Polaroid placeholder was a plain dashed rectangle until a photograph landed in
it and the chin appeared from nowhere. The two branches are folded together
now: the surround is drawn once and only what goes inside it differs, so a
frame shows what it will look like before it is filled.

Adding a page to a book with no inner pages yet put it in front of the cover,
where it could not be moved back from either — a move only ever swaps with
another inner spread, and the cover is not one. An empty book reports its last
inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now.

And snapping only knew the outer safe lines. A spread draws that dashed rule
inset from each of its four vertical edges, so dragging inwards caught the fold
and nothing else — the line you could see just before it was not a target. All
four are, along with the centre of each page and the quarters within it: on a
spread the halfway mark is the fold, so a picture centred on its own page had
nothing to find.

* feat(journey): take the auto layout's spreads from templates drawn by hand

The layouts written as code are what a function can reason its way to: a
picture here, words there, margins that add up. What they are not is designed —
a panel bled off one edge, a shape running off a corner, a strip of pictures at
two sizes with the coordinates set beneath them. Those decisions come from
looking at a page, and the editor is where looking at a page happens.

So a spread built in Studio can now be the layout. `extract-templates.cjs`
reads a journey's book and writes its spreads out as data, with every
measurement divided by the page it was drawn on — a template built on a 210mm
square lays out on an A4 landscape book without being redrawn. `applyTemplate`
does the other half: it fills what the entry can answer for and copies
everything else as drawn.

That division is the whole contract. Text carrying a binding takes the title or
the story; empty frames take the photographs in order; the day, coordinate,
date and country marks take what the stop knows. Panels, rules and decorative
shapes are the design and are left exactly as they were put.

A template is one *for an entry* when it uses one — which has to come out of
the drawing rather than a label somebody remembers to set. A summary spread of
figures and country outlines reads perfectly well on its own, and dropping a
day's photographs into it would be nonsense.

Two templates ship with this, drawn by the person who will use them. The
built-in layouts stay as the fallback for entries no template suits.

* feat(journey): turn elements freely, and pass a spread on as a file

Rotation by the corner, the way it works everywhere else: a handle sits
diagonally outside each of the four corners of the selection, and dragging one
turns the element by the change in angle rather than to the angle under the
pointer, so it can be picked up anywhere without jumping. Held to 15 degree
steps unless shift is down. Four handles rather than one because a single
handle off the top right sits under the inspector for anything in the right
page's upper half.

And a spread can now leave the book it was made in. The zoom bar downloads the
one on screen as a file; Import, beside Add spread in the pages rail, brings one
back. Measurements travel as fractions of the page, so a spread drawn on a 210mm
square lays out correctly in an A4 landscape book, the same way the auto
layout's templates do.

What deliberately does not travel:

  - Photographs. The file carries the design, and the frames arrive empty.
  - The map source. A static map freezes the URL it was rendered from, and on a
    Mapbox instance that URL has the access token in it, so a file meant to be
    posted in a forum would carry a credential. Coming the other way, a tile
    template from a stranger is a URL every reader's browser would fetch tile by
    tile: an IP address handed to whoever wrote the file.

An imported file is treated as what it is, somebody else's JSON. It is read in
the tab that chose it and never uploaded, parsed and never evaluated, and every
element goes through the document contract, which drops unknown fields and
range-checks the rest. Colours in particular are #rrggbb or nothing. The size of
the file and the number of elements are both capped, because the damage a
hostile file can do here is not code execution, it is handing the editor more
than the server will accept and leaving a document that will not save.

Also fixes the rotation handle being drawn but not grabbable: the chrome layer
and the selection box both pass pointers through, and a handle has to opt back
in with pointer-events.

* docs(wiki): add two Studio screenshots for the addon page

The editor with a stats spread open, and the frames panel. Both are what the
Journey addon page will want once Studio ships, and both are wanted now to show
the feature off while it is still in review.

* test(realtime): cover the book room's refusals and its dead sockets

The realtime coverage ratchet caught what the book room work left behind: the
three handlers each have an early return for a message that is missing its
journey id or comes from a socket with no user, and none of them was exercised.
Every one of those is a silent failure by construction. A handler that did
something with a missing journey id would not throw, it would put a socket
somewhere it does not belong, and nobody would notice until two strangers were
looking at the same page.

The other half is sockets that are in a room but cannot be used: a connection
that dropped before its close handler ran, or one put in a room without ever
being registered. The peer list has to walk past both rather than showing a
ghost, and the broadcast has to skip them rather than throwing on a path that
runs ten times a second per editor.

Both files are back to full branch coverage, so the domain's pins hold as they
are rather than being lowered to fit.

* feat(journey): make the route map a map of the journey

Four things were wrong with it, and they compounded.

**It showed the wrong place.** The view was fitted to the country outlines as
well as the stops, so a trip that stayed inside Berlin was drawn as the whole of
Germany with two dots in the middle of it. It fits what the journey did now, and
the outlines run off the edge of the frame the way a coastline does on any real
map. Fitting to the country is still there as a deliberate choice, for the page
every travel book has, and how much air to leave around either is a setting
rather than a constant.

**It drew a line nobody travelled.** Stops joined in order is a claim about the
journey that is almost always false: it crosses mountains, water and borders the
trip went around. TREK already stores routed geometry per place, from the
planner and from imported GPX, so the map now follows the road where the trip
knows it and falls back to joining the stops where it does not. Segments stay
separate, so a trip with a flight in the middle does not grow a road across the
sea. The geometry is thinned when it is frozen into the element: a printed line
gains nothing from a point every fifteen metres, and a book carrying every GPS
fix of a three-week trip is a document nobody can save.

**The tiled and static sources drew nothing at all.** The tile grid was
computed, the route was projected onto it, and then no picture was ever
rendered — the map came out as a frame with a line across it. Three separate
faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted
so every tile URL named a host that does not resolve, and the grid was cut from
the tiles the route happened to sit on rather than from what the frame shows, so
it stopped short of the edge. The frame decides the grid now, and it covers by
construction. Tiles overlap by a tenth of a millimetre, which closes the hairline
of white paper that rounding left between them.

Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static
Images API refuses it outright, so an instance configured for the planner's map
was configured with a style this cannot use. It falls back to the classic
equivalent, and a URL frozen into a document before that existed is repaired on
the way out rather than left broken for good.

**A map was always a rectangle.** It can be cut to the outline of the countries
now, so the picture *is* the country: no frame, no box, the page showing through
around the coastline. That is the version worth laying next to a photograph, and
the one thing a rectangle can never be. The tile grid and the vector fit are
projected from one extent so the picture and the shape it is cut to line up —
two fits meant the country was full of the wrong towns.

Also here: the credit line comes off the map itself. It is still resolved and
still stored, for the colophon, which is where a book has always credited its
sources.

* feat(journey): split the country list, and trim the map cards

The country list and the country grid were compositions somebody else made:
they decided the order, the spacing and the type size, and the only way to
change any of it was to not use them. They are gone, and what is left is the
same entry — the name over its silhouette, set the same way — as an element
placed one country at a time. Three down a page is the list, with the spacing
yours; one at 80mm is a chapter opener, which the list could never be. The name
is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather
than a feature. The compact mark, outline beside the name at the size of a line
of type, stays for the places that want a mark rather than a heading.

The map section offers the four vector styles and nothing else. Tiles, Mapbox
and the cut-out are one click away in the inspector, where the rest of the map's
settings already live, and four cards that are the same map four times were
crowding out everything under them.

A map is fitted to the country by default now, which is the picture a travel
book usually wants. Fitting to the stops is still there and still matters the
moment the trip is smaller than the country it happened in.

Also: no navigation fallback from the dev service worker. In production it is
right — offline, a deep link has to be answered by the cached shell. In
development it answers a reload with the index.html it cached earlier, whose
script tags point at module URLs from before the last restart, so the browser
re-runs the previous version of the app while the editor shows the new one.
Every explanation for that is wrong until someone thinks of the service worker.

* fix(journey): drop what the trimmed map section left behind

Three bindings that only fed the cards removed in the commit before: the map
sources hook, the country codes the cut-out card needed, and the frame the
preview sized itself from. Lint had them as warnings rather than errors, so
they would have ridden along quietly.
2026-08-19 23:36:42 +02:00
Maurice 7164f4fb11 fix(photos): drop the dead initialiser eslint flagged in the exif reader 2026-08-18 22:24:29 +02:00
Maurice 503a03792e test(photos): cover the local EXIF path so the memories ratchet holds
The capture backfill landed in src/nest/memories/ with its local branch
untested, which took the domain under its coverage floor (functions 96.71 vs 97,
branches 79.67 vs 81). Raising the tests rather than lowering the gate: the path
guard against climbing out of the uploads tree, a missing path, an unreadable
file, the CreateDate fallback, empty EXIF, a throwing row lookup, and schedule().
Plus the Immich album mapping with and without coordinates.

One note left in the test file for the next person: a mock that *rejects* makes
vitest fail the run even when the code catches it, because the mock's recorded
result is a second reference to that promise with nothing attached. A bare
mockReset() is enough to trip it, so the beforeEach sets a benign default.
2026-08-18 22:24:29 +02:00
Maurice 2c333cb643 perf(journey): render the gallery in pages instead of all at once
A long trip's gallery is hundreds of tiles and every one of them was in the DOM
from the first paint, which is what made scrolling it a chore (#1614). It now
renders 60 and grows as the sentinel comes into view — the same mechanism the
provider picker already uses, rather than a second one invented for this.

Deliberately not pagination over the wire: the gallery arrives inside the one
getJourneyFull payload that the client, the MCP tool, the MCP resource and the
PDF export all read, so cutting it there would change the answer for four
consumers at once and would silently shorten the book. The reported symptom was
the scrolling, and that is client-side.

The reported 500-photo limit is not in the code — no query limit, no upload cap,
no client slice — so nothing was raised.
2026-08-18 22:24:29 +02:00
Maurice 06b7f3e690 feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path
only ever asked for daily figures, so it answered with a rainy morning and a
bright afternoon averaged into one number (#1614).

With a time in hand it now asks for the hourly series as well and reads that
hour, falling back to the daily figures when the hour has no reading — an
archive gap must not turn into no answer at all. An unparseable time is treated
as no time rather than as midnight, which would have been a confident wrong
answer. The cache key carries the hour, so the two do not shadow each other.

Nothing changes for callers that pass no time, which is every existing one.
2026-08-18 22:24:29 +02:00
Maurice 97f86dcf46 feat(journey): let quick capture take a note, not just a photo
The ask was "photos or quick notes" while travelling, and the capture sheet only
took photos (#1614) — so a moment worth catching without a picture had no way in
short of opening the full editor, which is exactly what capture mode exists to
avoid.

Two rows rather than the full sheet's three: this is a note, not the story. The
story is written later, when the entry is finished.
2026-08-18 22:24:29 +02:00
Maurice 6e4fda897f refactor(journey): stop pulling trip photos into journeys
Photos live in journeys (#1614). Linking a trip used to copy its trip_photos
rows into the journey gallery, which is how a photo one member had chosen not
to share could reach a journey at all — PR #1967 filtered that at the door, and
this removes the door.

Nothing is lost by it: the trip-photo surface lost its UI in 3.1.0, so nothing
writes to trip_photos on an install newer than that, and the client calls none
of its routes. The table and those routes stay for one more release rather than
being dropped in an append-only migration; the read paths keep working for
installs that still hold rows.
2026-08-18 22:24:29 +02:00
Maurice 56b4509b03 feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their
coordinates are drawn as thumbnails on the Journey map, in the shape the report
asked for: a picture rather than an abstract pin, and a count when several
collapse into one.

Clustering is a screen-space grid rather than a library. The Journey maps have
never had clustering, and the one the planner uses hangs off react-leaflet while
this map drives Leaflet directly; bucketing by rounded pixel position is a few
lines, is deterministic, and does the job — photos of one place collapse into one
thumbnail with a count, and pulling the map apart separates them again. The layer
sits below the entry pins, because the itinerary is the point of the map and the
photos are context.

Its own effect, so photos arriving do not tear down and rebuild the map the way
the entry effect does, and redrawn on zoom and pan because the buckets are in
screen space.

The thumbnail URL is built in the page rather than the hook: it needs the share
token, which is the credential the byte proxy checks.

Also re-points the trailing-migration guard at the new last slot, which is what
its own comment asks of anyone appending one.
2026-08-18 22:24:29 +02:00
Maurice 11686be264 feat(journey): let a shared journey read newest-first
A journey shared while the trip is still running is read like a blog: the family
following along wants the latest entry at the top, not to scroll past three weeks
to find it (#1614). The owner sets it per share link, because it is a property of
how the link is meant to be read rather than of the journey, and the reader can
flip it either way.

Off by default, so an already-published link does not reorder itself under its
readers.

Only the reading order changes. Day colours and stop numbers stay chronological,
so the timeline still matches the map when the feed is upside down — which is why
the day index is looked up rather than taken from the render loop. Reuses
memories.newest/oldest, already translated in all 23 locales.
2026-08-18 22:24:29 +02:00
Maurice e809808572 fix(journey): give the public map one numbering instead of two
The marker showed the stop's position within its day; the day heading showed the
day number. Same colour, different meaning, and on mobile the heading is not
rendered at all, so the number had no key — which is exactly what the report
described: the same digits over and over in different colours (#1962).

Both now count stops across the whole journey, so every number appears once, and
the timeline carries the same number next to each geocoded entry. The day colour
still groups the day. The badge sits outside the photo-count gate on purpose: an
entry with a single photo puts its title and location in the image overlay, but
the stop number has to be readable either way.
2026-08-18 22:24:29 +02:00
Maurice 97220b72f6 feat(journey): carry capture metadata into the payload, and keep it behind share_map
The gallery rows now hand the client taken_at, lat and lng, which is what the map
and a capture-ordered gallery will read.

The coordinates needed a projection before they could ship. A photo's capture
location is a place the owner never typed and may not expect to publish, so it
follows share_map — the same switch the entry coordinates already follow. Without
that, turning the map off and leaving the gallery on would have handed out exactly
the locations the owner had just withheld. The capture time is not a location and
stays either way; a blog-style view sorts by it.
2026-08-18 22:24:29 +02:00
Maurice d2cf694882 feat(photos): read the EXIF of an uploaded photo
Closes the third source of capture metadata (#1614). Provider photos are asked
for, uploaded files carry their own answer — and until now nobody read it, so
the most common case of all, a photo taken on the trip and uploaded straight
into the journey, could never appear on the map.

exifr rather than a heavier image library: pure JS, no native build, so no
musl-pin dance in the Alpine image. Only four tags are picked, so a large file
is not fully parsed. The resolved path is checked against the uploads root
before it is opened.

One thing worth knowing before someone files a bug about it: the client
converts HEIC before upload and that conversion drops GPS, so an iPhone photo
arrives here already stripped. Nothing to read is the expected outcome more
often than not, which is why the empty case is silent.
2026-08-18 22:24:29 +02:00
Maurice 359c3afc21 feat(photos): ask the provider where and when, and stop the album path dropping it
Second half of the capture-metadata foundation (#1614).

The album paths were the asymmetry: Immich's search has always carried
exifInfo.latitude/longitude, but the album mapping quietly left them out, and
the Synology album listing never asked for the gps block at all. So a photo
picked out of an album could never be placed on a map, and the distance sort
that PR #1632 added silently had nothing to sort by. Both now carry the pair.

For the add itself the client only sends an asset id. Widening that contract
would have meant trusting the client for something the provider can be asked
for directly, and would have left the MCP path and every already-imported photo
without it — so PhotoCaptureBackfillService asks afterwards instead, detached,
skipping rows that already know both so an album import is not one provider
call per photo. A slow or unreachable provider cannot hold up the add, and a
photo without capture metadata stays the normal case.
2026-08-18 22:24:29 +02:00
Maurice 6185cf96b6 feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
2026-08-18 22:24:29 +02:00
Maurice 3e4b36ac71 fix(weather): give the Open-Meteo calls a deadline
All six outbound calls ran without an AbortSignal, so a hung connection held
the caller until the socket gave up on its own. Every other outbound client in
the codebase carries a timeout; this one is the reference domain and did not.
2026-08-18 20:17:57 +02:00
Maurice 85794a5a59 fix(journey): keep the picker's date headings chronological, and name any provider
Since the provider picker started sorting by distance to the entry, the date
groups were built from the already-sorted list, so first-seen order put the day
holding the nearest photo at the top and the headings stopped reading in order.
Grouping now sorts its own output; the order within a day is untouched, so the
distance sort still does its job where it belongs.

The picker also called every non-Immich provider "Synology Photos". It now
falls back to the provider's own id, the way the gallery view already did.
2026-08-18 20:17:57 +02:00
Maurice edb487fbef feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on
the map, in the PDF and on the public share — the desktop editor just never
rendered an input for it. So a draft carried the wall-clock time it was created
at, it showed up everywhere, and there was no way to correct it (#1614). The
column holds HH:MM:SS and a time input wants HH:MM, so both the initial value
and the dirty check now trim.

The camera gets its own hidden input rather than a capture attribute on the
existing picker: putting it there would take the photo library away on a phone,
which is the more common way in. The phone sheet already works this way.
2026-08-18 20:17:57 +02:00
Maurice 630e8efca1 fix(journey): stop unshared trip photos leaking into journeys, and gate the link
Two things found while reading the domain for discussion #1614.

A photo a trip member left unshared is denied on the trip path — memories-access
requires tp.shared = 1 — but linking that trip to a journey copied every row of
trip_photos into the gallery regardless. From there it was readable by every
journey contributor, and with an open gallery by anonymous visitors of the share
link. journey_photos.shared cannot gate that after the fact: a direct upload
writes 0 into the same column, so filtering on it would empty most galleries.
The fix is at the door instead — only shared rows are copied in.

addTripToJourney checked that the caller could reach the trip but never that
they could reach the journey, so any logged-in user could link a trip of theirs
into a stranger's journey and seed skeleton entries and photos there. The MCP
tool has always checked this; only the REST route was open. Now the service
checks it, which covers both and the internal call from createJourney.

Neither path had a test. They do now.
2026-08-18 20:17:57 +02:00
Maurice b267771772 fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and
it was the only way to reduce a busy map to one day. The phone shell does not
render DayPlanSidebar, so when it replaced the desktop page on phones that
control disappeared with it: expandedDayIds stayed null and every day's pins sat
on top of each other with nothing to do about it (#1962).

A day chip in map view now drives the same declutter the desktop chevron drives,
rather than introducing a second filter. Entering the map focuses the selected
day, tapping another chip moves the focus with it, and leaving the map clears it
so the timeline is never looking at a filtered state. Unplanned places stay
visible, exactly as the collapse path has always left them.
2026-08-18 17:40:56 +02:00
Maurice a3d1fc7808 feat(share): number the day's stops on the map and connect them in order
The public share page drew every geocoded place of the trip as an identical pin
with no sequence and no line, so an itinerary could not be read off the map at
all (#1962). It does filter to a day, but the only control for that sat below a
300px map, which is why the reporter never found it on a phone.

Markers of the selected day now carry their position in that day's order, with
the same contract the planner uses: a stop the day visits twice shows both, e.g.
"1 · 3". A dashed line connects them in order. It is a straight connector on
purpose, not a road route — drawing a real one would mean sending the itinerary
to a third-party routing service for every anonymous visitor of a shared link,
with no way for the trip's owner to opt out. A day picker now sits directly
above the map, driving the same state as the day cards below, so the two can
never disagree.

Two things nobody reported, both found while in here. Outside a day selection
every marker fell back to the placeholder indigo, because the payload nests the
category on a day's assignments but sends it flat on the trip-wide pool and only
the nested shape was read. And the map refit itself whenever the page
re-rendered for an unrelated reason — the language picker, a late FX response —
because the effect keyed on the array identity of a list the page rebuilds every
render, throwing away the viewer's pan and zoom. It keys on the coordinates now.
2026-08-18 17:40:56 +02:00
Maurice f4ee06e931 fix(share): stop the day header collapsing the title into a one-word column
The day card header on /shared/ is a single nowrap flex row, and its title block
carried `flex: 1`. That is a flex base size of 0, so the moment an accommodation
chip pushes the row past the available width the block freezes at its min-content
width while the chip and the place count stay at full content width — the ~37px
column with one word, or one CJK character, per line from #1955.

It is content-driven, not viewport-driven, which is why it also showed up on a
1149px-wide desktop window: one long hotel name, or a day carrying both a
check-out and a check-in, is enough. A breakpoint would have been the wrong fix.

The title block now shrinks proportionally and truncates, the chip can shrink and
truncates too, and the place count is pinned so it never wraps or gets pushed out
of the card. The chip also moves off its hard-coded 9px onto the 10.5px the day
sidebar uses in the app, with an icon to match — at 9px it was barely readable.
2026-08-18 17:40:56 +02:00
Maurice 28765e220b fix(places): keep the Google feature id, and give list imports the free address lookup
Two halves of #1954, both about a place arriving through a list coming out
thinner than the same place pasted as a single link.

The feature id was never dropped on purpose. googleFtidFromMapsUrl only looked
at the `?ftid=` query parameter, while a share link that resolveGoogleMapsUrl
has followed to its final hop carries the id in the `data=!…!1s0x…:0x…` path
blob — the same run the coordinates are already parsed out of two lines further
down. So every pasted maps.app.goo.gl link lost it, and the Maps button opened
a name search rather than the entry the link pointed at. The parser now reads
the blob too, but only on a /place/ URL: a /maps/dir/ route carries one !1s per
waypoint and the first is the origin, which would attach the wrong place.

List imports never had a reverse lookup at all. The only pass behind them is
the Google enrichment, which returns immediately without a Maps key, so an
instance without one imported a list and left every address empty forever.
They now get the same free Nominatim lookup the single-link path has always
had: detached after the import answers, on the throttled background lane so it
never queues in front of someone's search, through COALESCE so it can only fill
an empty column, and each row pushed over the websocket so the sidebar fills in
without a reload. Capped at 250 places — past that it stops being a backfill
and starts being bulk geocoding.

Not included: an osm_id for imported places. Single links do not produce one
either, and one derived from a zoom-18 reverse lookup would name the building
or the street rather than the POI.
2026-08-18 17:40:56 +02:00
Maurice c9278c02ce chore(deps): lift the four packages the Scout gate flags as high
The security scan on the release PR has been red on six HIGH CVEs, all of
them transitive production dependencies of the server workspace, which is
what ends up in the runtime image via `npm ci --workspace=server --omit=dev`:

  brace-expansion  2.1.1 -> 2.1.4   CVE-2026-13149 / -69152 / -14257, ReDoS
                                     reached through archiver -> glob -> minimatch
  ip-address      10.2.0 -> 10.5.0  CVE-2026-69192, improper input validation
                                     reached through the MCP SDK -> express-rate-limit
  fast-uri         3.1.4 -> 3.1.5   CVE-2026-18446, reached through the MCP SDK -> ajv
  js-yaml          4.3.0 -> gone    GHSA-5p4m-2wfm-xmqj

Every one of them resolves inside a range the manifests already declare, so
this is a lockfile change and nothing else: no new dependency, no override,
no package.json edit.

js-yaml was the awkward one. @nestjs/swagger pins it exactly rather than by
range, and 11.4.5 pins the affected 4.3.0. 11.4.7 pins 5.3.0 instead, and
server already asks for ^11.4.5, so the affected copy leaves the production
tree without forcing anything. The root js-yaml 4.3.0 stays for eslint, which
never reaches the image.

Walking the production graph over the lockfile gives the same 482 packages
before and after, with only those four differing. Server suite is green
(409 files, 7805 tests), as are typecheck, typecheck:tests and client lint;
/api/docs-yaml still renders through swagger's new js-yaml.

Still open and deliberately not in here, both MEDIUM and below the gate's
critical/high filter: dompurify (via isomorphic-dompurify, and #1694 reverted
a bump of it once already) and hono (via the MCP SDK).
2026-08-18 16:11:03 +02:00
34ezz623-create 3011bf50b4 refactor(i18n): improve Turkish translations for vacay module
Replaces the machine-translated strings in the Vacay domain. The visible ones:
'Doygunluk' (saturation) and 'Güneş' (the star) were the Saturday and Sunday
column headers, 'Sol' (left, as in direction) stood for the remaining days,
'Efsane' (myth) for the legend, and 'Sigortala' (insure) read "fuse" as an
electrical fuse. Buttons move from the infinitive to the imperative the rest
of the locale already uses.

Rebased onto dev and resolved as a union: the branch carried the file as it
stood on 25 July, which would have dropped the 35 keys dev has gained since
from #1074, #1631, #1633 and #1637. Those keep their existing translations;
only the 47 strings this change actually rewords differ.

vacay.selectRegion keeps '(zorunlu)'. en says '(required)' and the Add button
stays disabled until a region is picked, so '(isteğe bağlı)' would have sent
Turkish users looking for a button that never enables.
2026-08-18 13:58:58 +02:00
Maurice 408e39b9d3 docs(wiki): list the two transport-mode tools in the MCP catalog
Every other tool on those two controllers has a row; these were the only
ones missing.
2026-08-18 13:56:44 +02:00
Maurice 02f7320c90 fix(mcp): name the route profile keys the router actually accepts
Both tool descriptions offered "transit" as an example. RouteCalculator only
knows driving, walking and cycling plus the plugin:<id>/<profile> form, and
maps anything else to driving when it draws the route, so an agent that set
"transit" got a success and a car route with a car icon. Name the real keys
and say plainly what happens to anything else.

The incoming-leg test used "transit" too. It now uses walking, and the
outgoing test seeds the incoming column first, so its "must not touch
incoming" assertion proves something the factory did not already give it.
2026-08-18 13:56:44 +02:00
Ida Rogalska e375f9e002 feat(mcp): add set_day_default_transport_mode tool
Wrap DaysService.setDefaultTransportMode behind an MCP tool, mirroring the
days/:id/transport REST route. Adapter only; per-segment leg modes still
override the day default.
2026-08-18 13:56:44 +02:00
Ida Rogalska 03f3c5ae36 feat(mcp): add set_leg_transport_mode tool
Wrap the existing per-leg transport setters (outgoing + incoming) behind an
MCP tool so leg modes no longer need a raw DB write. Adapter only, no new
business logic; mirrors the assignments/:id/transport REST route.
2026-08-18 13:56:44 +02:00
michael-bohr 55aef8d55c fix(tests): generate trip days in UTC so a DST shift can't drop or repeat one
The test trip factory parsed date-only ISO strings as UTC midnight but stepped
the cursor with setDate(), which advances local wall-clock time. Across a DST
transition that step is 23h or 25h, so the instant drifts off midnight and
toISOString() reports the wrong day — a day is dropped in autumn and repeated
in spring.

TRIP-SVC-015 already builds a trip over 2025-11-01..2025-11-03, which straddles
the US fall-back, and fails on any machine in a DST-observing zone:

  AssertionError: expected [...] to have a length of 5 but got 4

CI runs UTC, where the loop happens to be correct, so this is invisible there
and only bites contributors running the suite locally.

Full server suite: 396 files, 7550 tests, green.
2026-08-18 13:37:06 +02:00
Maurice 412de9df4d docs(readme): serve the tour as animated webp instead of a 91 MB gif
Same 1110 frames, same 1100x618, same behaviour in the page: it is still an img,
so it starts on its own, loops forever and has no player chrome. GitHub strips
autoplay and loop off a video tag, so a video was never an option here anyway.

91.0 MB to 18.7 MB, and it looks better rather than worse. The gif was capped at
256 colours, which is visible as banding in every gradient the tour passes over;
the webp has none. Encoded at quality 60.

Both files sit in the same release, so the gif stays reachable for anything that
still points at it.

The alt text dropped its duration claim. It said a 60-second tour, and the clip
runs 111 seconds.
2026-08-17 19:49:12 +02:00
Maurice e62e76713f docs(readme): repoint the tour gif, and tidy the top of the page
The 60-second tour has been a broken image since the org move. It points at
liketrek/TREK-media, and that repository does not exist; the release asset still
sits in mauriceboe/trek-media, which is where the src goes now. On the rendered
page the gif reports naturalWidth 0, so what a visitor sees under the badges is
an empty gap.

Around it, three things that were only layout:

The screenshot rows move below the feature list. Five full-bleed images ran back
to back before the reader got a single instruction, and the tour gif and the rows
show the same screens anyway. Motion first, then the summary graphic, then the
detail, then the stills.

The second horizontal rule goes with them, so one rule closes the header block
and the rest of the file separates sections the way it already did, with a break
and a heading.

The jump links had a separator dangling off both ends of the row.

The subtitle image says "your trip. your plan." while its alt text said "Your
trips. Your plan. Your server.", so anyone on a screen reader got a different
tagline than everyone else. The alt now reads what the image reads.

The tile SVGs gain width and height attributes. They only ever render through an
img that sets width, but without an intrinsic size of their own they fall back to
300px wide anywhere else.

The badge block stays as it is. Read on the rendered page rather than in the
markup, the two badge weights are a hierarchy rather than a clash.
2026-08-17 19:49:12 +02:00
Maurice 1f92d47452 Remove extra line in README.md
Removed an empty line before the subtitle picture.
2026-08-17 19:49:12 +02:00
Maurice 299c97ac9f docs(readme): correct the feature list against the code
Every line under "See all features" was checked against source, not against the
docs. What was wrong:

- 20 languages. There are 23; ca, sv and vi were missing from the list
- 27 scopes in 13 groups. There are 29 in 14
- 150+ tools. There are 199, and 4 prompts rather than the 3 named
- GeoJSON import. No parser exists; places.service.ts takes gpx, kml and kmz
- default coordinates under Units. No setting by that name exists anywhere
- day check-ins under Collab. The fourth sub-feature is a What's Next list of
  upcoming activities; nothing checks anyone in or out of a day
- Apple as an OIDC provider. The discovery check requires a userinfo_endpoint,
  which Apple does not publish, and the callback is GET only. Also only one
  provider can be configured at a time
- role-based access. trip_members has no role column; access comes from an
  admin-set matrix over 16 trip actions
- one-time invite links. A trip link is one reusable rotating token with an
  optional expiry; the use limit belongs to the admin signup invites
- iOS-style bag distribution. It is a fill bar against an optional limit, and
  bag tracking is an instance switch that ships off
- fullscreen with a splash screen. The manifest says standalone, and there are
  no splash assets; only Android derives one from the icon
- the service worker caching the API. /api is NetworkOnly on purpose, because
  Workbox keys by URL and cannot vary on the session cookie
- Atlas streak tracking. It counts consecutive years travelled, not countries
- approved school holiday overlays. They come from openholidaysapi.org and are
  capped to 16 European countries
- 50 MB per file. Video goes to 500 MB

Missing outright: the plugin system, which the file did not mention once, sandbox
and SDK included. Also Collections and AI Parsing as addons, public share links,
guest members, the ICS feed, Transitous, Overpass, OSRM route profiles, GPX
export, place enrichment, the audit log, multi-leg flights, and the offline
machinery past "caches tiles".

The Addons box duplicated Costs and Packing, which are the same switches as the
core lines above it. It is gone, and the default state sits on the line instead,
which is what a self-hoster actually needs to know.
2026-08-17 19:49:12 +02:00
Maurice 1f3567d928 docs(readme): free-standing cards, and drop the compound media query
Opera serves the stacked phone art to a full-screen desktop window whenever the
colour scheme is dark, and Chrome does not. The one unusual thing in the markup
was the first source, which combined two conditions:

    (prefers-color-scheme: dark) and (max-width: 700px)

GitHub only documents prefers-color-scheme in a README picture, so that source is
gone. No arrangement of single-condition sources can express "narrow and dark", so
width comes first and the colour scheme second: a phone in dark mode now gets the
light stacked art. Unreadable text is broken, a light card on a dark page is not.
grid-mobile-dark.svg goes with the source that referenced it.

The three fields also stop being compartments in one slab. They are three cards
now, each with its own wash, border and lift.

Type is a grade larger throughout because the old sizes were computed against a
1012px column. Measured on the rendered page, GitHub's README column is 838px, so
the label was landing at 14px and the caption at 13px.
2026-08-17 19:49:12 +02:00
Maurice 5440e76223 docs(readme): redraw the feature graphic, move the phone row up
The nine-tile grid gave every tile its own pastel, a two-colour headline, a ghost
icon behind it and the same eyebrow, headline, triplet rhythm, nine times without
a break. Three fields now, one glass panel each, one icon, and the detail stays in
the block underneath where it already lived.

Monochrome, and shipped as a light and dark pair through the picture element the
way the logo above it already is, so it stops being a bright slab on a dark page.
The ink is the app's own neutral accent (#111827 and #E4E4E7) instead of an
invented brand colour, and the panels follow the surface recipe from
client/src/styles/dashboard.css: transluent gradient, hairline border, highlight
on the top edge, soft lift. The backdrop blur cannot come along, an img-embedded
SVG sees nothing behind itself, so the wash it would have blurred is baked in.

Poppins is drawn as outlines for the same reason: that SVG may not load an
external font, so a font-family would have fallen back to whatever the reader
happens to have installed.

Also puts the phone row between the two desktop rows rather than after them.
2026-08-17 19:49:12 +02:00
Maurice 824834c8fe docs(readme): swap the animated logo for the static mark
The header opened on a 162-frame GIF, 878 KB across the light and dark pair for
an animation that runs before anyone has read a line. docs/ now carries the same
mark as the SVG the app itself ships, so the README header and the navbar show
one logo rather than two renderings of it, and the picture element keeps
switching it per colour scheme.

The GIFs stay in docs/ unreferenced, in case the animation is wanted elsewhere.
2026-08-17 19:49:12 +02:00
Maurice 92533afea3 docs(readme): replace the screenshot grid with the release showcase rows
The eight tiles were one screen each at 49% width, so the README opened on a
mosaic of unrelated crops. The showcase renders say the same thing in three
full-width rows: atlas, dashboard and the planner map, then collections, the
journal and costs, then the mobile lineup.

They ship as WebP. The same three frames as PNG are 14.3 MB, which every clone
carries forever and every README view downloads. At quality 92 the full 4292px
render is 1.1 MB for all three and a 1:1 crop is indistinguishable from the
source. .gitattributes learns the extension, since png, jpg and gif were listed
there already.

Nothing but the README pointed at the eight files, so they go with it.
2026-08-17 19:49:12 +02:00
Maurice 84d9fbff3c fix(scripts): rotate the instance-wide provider keys too
#1939 moved the Google Places and Unsplash keys into encrypted app_settings rows,
but the rotation script walks a hardcoded list of app_settings keys and the two
new ones were not on it. Rotating ENCRYPTION_KEY would have left them encrypted
under the old key, which reads back as null and silently drops the install to
OpenStreetMap with nothing in the log to say why.

users.unsplash_api_key was missing from the users column list for as long as the
column has existed. It matters now that the resolver reads that column as the
per-user fallback, so it goes in at the same time.

The script cannot import the canonical list, since it deliberately runs without
config.ts and without the container, so the copy is pinned by a parity test that
reads the script and fails when a name is missing from the app_settings loop.

Wiki: the rotation page listed the Maps key as per-user and did not mention the
app_settings rows an operator now has to carry across.
2026-08-17 18:33:43 +02:00
Maurice 1e166ab0d9 test(server): raise the coverage floor for the four domains this branch covered
Measured values with a point of slack, from scripts/coverage-thresholds.mjs:
atlas, audit, settings and unsplash all gained cases here.

Only those four. The script prints a block for every domain, and taking it whole
would have lowered src/nest/auth lines from 96 to 95 and tightened domains this
branch never touched, which would fail the open PRs for no reason.

unsplash is the one that needed the work rather than just recording it: dropping
the "any admin's key" fallback removed covered branches, so the parse-failure
ternary that was already uncovered started dominating the ratio and the gate
failed at 80.85% against a floor of 82%. Covered it instead of lowering the floor,
which put branches at 89.36%.
2026-08-17 14:48:23 +02:00
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00
Maurice d156404b84 perf(atlas): bound the region layer to what is actually on screen
Interaction degraded the longer the map was used past zoom 5 and a reload fixed
it, which the report traced accurately: admin-1 geometry was fetched for every
country whose bounds crossed the viewport, the cache was never evicted, and the
whole accumulated set was torn down and rebuilt as one layer on every addition.
Panning past Canada or Russia added their geometry to every later rebuild for the
rest of the session.

Only the countries in view are rendered now, a signature over that set skips the
rebuild entirely while panning inside the same countries, and the cache holds a
dozen countries, never evicting one that is on screen or still loading.

One cause was not in the report and is probably the bigger one: a fresh renderer
was constructed on every rebuild. Leaflet registers a renderer as its own map
layer and removing the GeoJSON layer does not remove it, so the pane collected an
orphaned element per rebuild that kept updating on every pan for the rest of the
session, and at the country layer each orphan is a canvas of four times the
viewport area. Both renderers are created once per map now.

The region layer stays on SVG on purpose. Leaflet's stylesheet only makes SVG
paths mouse-transparent, so a canvas container would swallow the clicks meant for
the country layer below zoom 5.

Closes #1950
2026-08-17 14:48:23 +02:00
Maurice 54693474f1 fix(atlas): stop counting a connection airport as a country visited
The role IN ('from','to') filter from #1486 is correct and still in place, but it
cannot see the case this issue actually reports. Joining AirTrail legs into one
booking with a layover stop is opt-in, so legs imported separately store the hub
as the legitimate `to` of one booking and the `from` of the next, which no role
filter can tell apart from two real arrivals.

Pair them where the countries are counted instead: an arrival and a departure at
the same airport, in different bookings of the same trip, within 24 h of each
other are a connection, and neither endpoint counts as reached. 24 h is the same
window the importer already uses to chain same-PNR legs. Doing it on the read side
means the databases that already hold these flights heal on the next request.

Only flights pair, and never cancelled ones, so a rental car picked up at the
airport still counts its country (#1366). A booking that returns to where the
chain started is a return flight, and that check compares distance rather than
airport identity, so flying home into a different airport of the same metro area
does not erase the country you spent the day in. A country with a place logged in
it, or marked by hand, is untouched.

Closes #1535
2026-08-17 14:48:23 +02:00
Maurice 1a0834b866 fix(maps): resolve the Places key per instance, not per whoever is asking
Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
2026-08-17 14:48:23 +02:00
Maurice c9124c9b63 fix(places): only attach tags owned by someone on the trip
A tag belongs to a user, not to a trip, and the place create/update body is an
open record the Zod pipe does not police — so any tag id could be attached, and
the tag projection ships tags.user_id, which answers who owns a tag the caller
has no way to see. collection_place_tags had it verbatim.

Scoped to the roster rather than to the caller on purpose: a member tags a shared
place, another member re-saves it and, because the update is delete-then-insert,
sends that id back. Caller-scoping would drop a co-traveller's tag on every
foreign edit; roster-scoping keeps it. Collections resolve the same question
through their own member list.

No client sends a tags array today, so this only closes the hand-crafted path.
2026-08-16 23:17:50 +02:00
Maurice 244eb56e4c fix(budget): confine payers, split members and settlement parties to the trip
knownUserIds asked whether a user exists at all, not whether they are on this
trip, so any id on the instance could be made a payer or a split member. Both
read-backs join users, and the settlement projection carries the avatar through
avatarUrl, which passes an absolute URL straight out — for an OIDC account that
is the picture claim.

Payers and split members drop off-roster ids, as before, only against the right
set. Settlements cannot: they name exactly two parties and both columns are NOT
NULL, so dropping one would either break the constraint or move money to the
wrong person. Both wrappers refuse the write and return null, which lands on the
404 the update path already had. That answer is deliberately the same one a
missing settlement gets, so the endpoint says nothing about whether an id exists
— and it retires the foreign key error that used to surface as a 500 and drew
exactly that distinction.

createSettlement grows the 404 its sibling had; the MCP tool reuses the error
string already at hand. Money rules are untouched: a settlement is a transfer, so
refusing one cannot unbalance the sum, and the cent arithmetic is unchanged.
2026-08-16 23:17:50 +02:00
Maurice 0337ebbbd2 fix(assignments): confine day participants to the trip roster
setParticipants took the ids on trust. The caller is proven to be on the trip and
the assignment is proven to sit on it, but nothing said the same about the ids in
the body, and the read-back joins users — so a stranger's name and avatar came
back to the writer and went out over the assignment:participants broadcast to
everyone else in the room.

Takes the tripId both callers already hold and filters against the roster. Same
drop-rather-than-reject rule as bag members and reservation travellers: the
participants box posts the whole list on every edit, so a trip whose membership
changed underneath it would otherwise be stuck.

ASG-SVC-028 covered a foreign key error rolling the replace back. The filter
takes an unknown id out before the insert can reach it, so that path is gone and
the case now pins what happens instead: a list of nothing but strangers clears,
like the empty list ASG-SVC-018 already pins.
2026-08-16 23:17:50 +02:00
Maurice 1eb29f8b77 fix(trips): confine assignee writes to the people on the trip
Holding packing_edit or todo_edit says you may edit this trip's lists, not that
any user id you name belongs to it. Both updateCategoryAssignees loops wrote the
body's ids straight through, and both end by re-selecting through a JOIN on
users — so an id from anywhere on the instance came back with a display name and
an avatar attached, in the response and in the WebSocket fan-out to the room. The
packing item recipient writes had the same gap.

packing already had tripRosterIds for exactly this and used it only for bag
members; its doc comment said "assigned to a bag", which is probably why the
other three writes grew up without it. The query moves to DatabaseService next to
canAccessTrip, which asks the same question as a predicate, and the helper now
delegates. Every service that needs it already injects DatabaseService.

Off-roster ids drop rather than 400: a copied trip carries assignee ids over
before its members exist, and rejecting the whole request would stall the picker
there. Guests are unaffected — a guest is a trip_members row, so the roster
includes them, which is the point of #1362.
2026-08-16 23:17:50 +02:00
Maurice 5bc041cc77 fix(journey): escape the tooltip text on the Leaflet maps
A string handed to bindTooltip becomes innerHTML. JourneyMap passed an entry
title and a track name through untouched; the GL renderer beside it escapes
both already, and JourneyPublicPage imports the Leaflet one directly. The atlas
bucket-list tooltip had the same gap.

Also pins the escape on MapViewGL's photo marker. Its Leaflet twin has
FE-COMP-MAPVIEW-074 for this, but the GL side was only ever exercised with
benign values, so escapeHtml could have been dropped there without a test
noticing.
2026-08-16 21:57:21 +02:00
Maurice c8607ba234 fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
2026-08-16 21:57:21 +02:00
Maurice 0f0bf50cd3 fix(assignments): guard the per-assignment routes like the day routes
AssignmentOpsController declared @RequirePermission('day_edit') on its
mutations but carried only JwtAuthGuard. TripAccessGuard is what reads that
decorator and what resolves :tripId, so on this controller the permission
markers were metadata nobody looked at and the trip in the URL was never
matched against the caller. Its sibling, DayAssignmentsController, has had the
pair since the migration.

The two participants handlers also went straight to the service with the id
from the path, unlike move/time/transport, which ask getAssignmentForTrip
first. They ask now too.
2026-08-16 21:57:21 +02:00
Maurice ff27624e02 fix(reservations): scope the per-day position write to the trip
updatePositions has two branches. The legacy one updates
`WHERE id = ? AND trip_id = ?`; the per-day one, three lines below it, bound
the caller's ids straight into reservation_day_positions and never touched the
tripId it was handed. That table has no trip_id of its own and its two foreign
keys only require the ids to exist, so nothing further down caught a pair that
did not belong together.

The trip now comes from the join, so the row materialises only when the
reservation and the day agree on it. Doing that in the statement rather than as
a pre-check also turns a stale id into a quiet no-op instead of a foreign-key
error surfacing as a 500, and lets an omitted day_plan_position bind the way
the wire contract has always said it may.

reorder_reservations reports a day that is not on the trip, the way its sibling
tools in that file already do, and a migration clears rows whose reservation
and day disagree.
2026-08-16 21:57:21 +02:00
Maurice 688eea23a4 fix(dashboard): keep the trip card actions on the cover and give them contrast
The edit/duplicate/archive/delete buttons sat at opacity 0 until the card was
hovered, over a white .18 fill. Two people in a row said they never found how
to unarchive or rename a trip: invisible at rest, and white-on-white over a
light cover even while hovering.

They stay on the card now at .92, the fill is a dark scrim so the white icons
carry against any photo, and the icons get a heavier stroke. The status pill
and the hero badge move to the same scrim so the cover overlays read as one
set. An archived card shows the restore icon rather than the archive one,
which is what mobile already did, and every button gains a title next to its
aria-label.
2026-08-16 21:57:21 +02:00
jubnl b1e4c830b2 fix(immich): strip leading / in immich url (#1948) 2026-08-15 23:05:44 +02:00
jubnl 2bffee8fd4 fix(auth): preserve remember-me when password change re-issues the session (#1927) 2026-08-15 22:48:22 +02:00
jubnl 72221a2e45 feat(auth): sliding session renewal past half the token lifetime (#1927) 2026-08-15 22:48:16 +02:00
jubnl 61e03fdfde feat(oidc): honor remember-me in SSO login (#1927) 2026-08-15 22:48:03 +02:00
Maurice cb41d082be feat(admin): make an admin section linkable with ?tab=
The admin page always opened on Users, whatever the address said. A support
reply, an onboarding mail or a bookmark could point at the page but not at the
panel it was about, so every one of them ended with "then click X on the left".

Read once for the initial value and written back on each change, so the address
bar keeps saying where the reader is. Replaced rather than pushed: eleven tabs
would otherwise fill the back button with the history of a single page.
2026-08-15 18:07:21 +02:00
Maurice a6c27aac51 fix(admin): stop asking for OIDC config where the route is closed
On a centrally administered install /api/admin/oidc answers 403, and the admin
page asked anyway. The promise was caught, so nothing broke, but the request
still went out and still printed a red 403 in the console of every admin who
opened the page. Swallowing a rejection hides a failure from the code, not from
the person reading the console.
2026-08-15 18:07:21 +02:00
Maurice f2329c9a63 feat(client): let a managed surface attach as an admin tab
The nav-entry seam was the wrong shape for what actually attaches here. A
subscription surface is about the install, not about a trip: /admin is already
behind an admin check, already grouped, and already where somebody looks to
change how their instance works. A top-level nav entry put it beside My Trips
for a screen opened once a month by one person.

Two lines in AdminPage rather than an overlay replacing the file: the tab list
gets the extra entries appended per group, and the body renders whichever one is
active. Appended rather than inserted because PageSidebar needs a group's tabs
contiguous.

The route and nav-entry seams stay. They are the right answer for a surface that
genuinely is a page, and both remain empty here.
2026-08-15 18:07:21 +02:00
Maurice 887e6c5026 feat(client): let a managed route require an admin
Hiding the nav entry is not access control: the path stays typeable, and a screen
about how the install is run should not open for somebody who was invited to one
trip. ProtectedRoute already knows how to refuse — the attachment point just was
not asking it to.

Optional, so a route that does not set it behaves exactly as before. It sits
next to adminOnly on the nav entry rather than being derived from it, because
the two answer different questions: who is offered the door, and who may walk
through it.
2026-08-15 18:07:21 +02:00
Maurice 7c378c574f feat(client): let a managed nav entry be admin-only
The attachment point hands the two nav bars a static list, so anything attached
there showed up for every user of the instance. What belongs there is the
operator's side of the install, and a trip's other travellers are on the instance
as guests of the person who runs it — an entry about how it is run is noise to
them.

`adminOnly` is optional and absent means "for everyone", so nothing that exists
today changes meaning. Both nav bars go through the same exported filter rather
than each writing the rule, since a rule written twice is a rule that will differ.

Still inert here: the list is empty, and the tests pin that an empty list stays
empty for either kind of user.
2026-08-15 18:07:21 +02:00
Maurice 05315ef7d9 fix(planner): pin the clock in the opening-hours test
FE-PDC-019 asserts that the collapsed row shows one specific line, but the row
shows the current day in the place's timezone — and the fixture gives Saturday
different hours from the working week. So the test passed Monday to Friday and
took the build down every weekend, which reads as a broken feature rather than a
test that forgot to say which day it is standing on.

Pinned to a Wednesday, with the Saturday case kept as its own test rather than
thrown away: it is the one that proves the day lookup happens at all, and it is
the exact case that was failing.
2026-08-15 18:07:21 +02:00
Maurice 3a0604b3f5 feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED

The switch landed last release with nothing behind it. This is what it turns
off, plus the four fixes that came out of walking every one of these surfaces.

Nothing here changes anything for an install that does not set the flag. That
is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for
any unparseable value, no other variable can turn it on, every settings key
stays writable, and splitManagedKeys hands back the same object rather than a
copy so no write path can quietly diverge on self-hosted installs only.

WHAT THE MODE WITHHOLDS

Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT
rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and
reload, the Ollama model list and pull, and the SMTP test send. Each names the
reason it is not the instance admin's, and the reason is mandatory.

Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles
and the WebAuthn pair share one handler with keys the admin does own, and the
llm_* family is reachable through per-user settings, the instance defaults and
the addon config alike, so a route-level refusal would be both too coarse and
incomplete. A locked key is skipped and reported as managed_keys; the rest of
the save goes through. Refusing the request would take the registration toggles
down with the SMTP block.

GET /api/auth/me/settings stops returning the three API keys in the clear. It
is the only endpoint that decrypts a stored credential back to the caller, which
is fine when the admin pasted it in and wrong when the operator supplied it.

Two multipart routes check in the handler rather than the guard: a guard throws
before the parser drains the body and the client gets an ECONNRESET instead of
the 403 (PROFILE-015). They still carry the marker so the boot gate sees them.

WHAT KEEPS IT FROM DRIFTING

validate-managed-routes.ts refuses to boot on a marking that is not on the
reviewed list, a list entry whose route lost its marking, or a marking with no
reason. The third case has no equivalent in the guard gate next door and is the
one that matters most: a blank reason destroys the only record of why a control
was taken away, and nothing recovers that later. It found two wrong entries in
my own list while I was writing this.

managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and
DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to
either list belongs to neither until somebody decides, and that is the failure
this prevents: the lists grow, the mode does not.

PLACES_API_BASE AND PLACES_API_KEY

The nine Places endpoints spell out googleapis.com, so an install that wants
those calls to leave through something of its own has no way to say so. One
variable, substituted where every call already funnels through. A second one
supplies the key, and when it is set the users table is not consulted at all —
a per-user key would route around whatever the endpoint counts, and those
columns are readable by any admin of the instance.

FOUR FIXES THAT ARE NOT ABOUT THE MODE

An admin setting somebody's password now bumps password_version and clears
their MCP tokens. An admin sets a password for one reason, and until now
verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the
one action taken to lock them out was the one action that did not.

The four OIDC provider calls go through the SSRF guard. Three are worse than
they look — the token, userinfo and JWKS URLs come out of the discovery
response, so they are chosen by the other end, not by an admin. safeFetchLlm is
renamed safeFetchAdminConfigured and keeps its old name as an alias, because it
is the variant that allows loopback and LAN: a self-hoster with Authentik on
localhost:9000 must keep working. The token exchange follows no redirect at all,
since one would hand client_secret to a second host.

DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half
has existed since passkeys landed; the TOTP half never did, which left
"somebody on the trip lost their phone" with no answer short of an operator in
the database. Never for the caller themselves.

TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally
administered. A warning is right for a machine its owner chose to trust; where
the person who set the flag and the people whose data is in the database are
not the same person, fail closed.

TEXTS

Four strings lose the phrase "self-hosted" because they describe somebody
else's software — an Ollama endpoint, an AirTrail instance, a service a plugin
talks to — and read better without it in both modes. The one string that
describes TREK itself keeps it, and the mode picks a second key instead, so
self-hosters lose nothing.

A grep gate in shared/ keeps the canonical locale honest, because the parity
check compares the file set and the top-level keys and never looks at a value:
re-wording a string is invisible to CI today, and so is putting it back.

* feat(maps): supply the map credential with the instance when centrally administered

MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value.
SettingsService injects it when the settings are read, and picks GL as the
provider if the reader is still on Leaflet, since a token is always present.

The key moves from the customer set to the locked set. It is the one locked key
whose value is public by design — a pk.* token has to reach the browser to be
useful — so this is not about secrecy: a per-user save landing on top of the
instance token would only break the map for that user. Both settings fields for
it go away with it.

* fix(auth): count the operator key when reporting whether Google features work

has_maps_key only looked at the users table, but getMapsKey prefers the
PLACES_API_KEY env over anything stored there. On an install that supplies the
key that way and has an empty column, every lookup worked while the client was
told there was no key and hid the search, the autocomplete and the place photos.

Found by walking the running instance rather than the code: the map came up,
the search box did not.

* feat(admin): hide the settings a centrally administered install cannot act on

A control that answers 403 is worse than no control: the AI section rendered the
refusal as "Request failed with status code 403", which tells an admin they broke
something rather than that the decision is not theirs.

So the surfaces go rather than being disabled in place:

- AI parsing and AirTrail leave the addon list, and the AI addon route now
  refuses outright. The endpoint, the model and what a document costs come with
  the instance, so there is no instance decision left — not even whether it runs.
- Immich and Synology Photos leave it too. Both connect to a server the reader
  runs at home, and there is no route from here to one.
- The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and
  origins follow the same rule: they configure something the operator owns, and
  a wrong RP ID on a shared parent domain reaches past this instance entirely.
- The About tab goes. It is about the project — what TREK is, where to file a
  bug, where to support it — and is aimed at the person running the software.
  The version and the link to the source move to the sidebar footer, on both the
  desktop page and the mobile twin, because AGPL 13 wants that offer wherever
  people reach the software over a network.
- The update check stops asking GitHub and reports nothing available.

Separately, the version footers lose the "self-hosted" suffix everywhere, not
just here. It read as a label on the software rather than on the install, and
the version alone says what it needs to.

* feat(config): refuse to boot centrally administered without an at-rest key

Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset,
the at-rest key falls back to a file in the data volume, and backupService
bundles that file into every archive it builds. The archive is downloadable by
any instance admin, so the encryption ships with its own key in the same zip.

The skip in backupService already handles this — it only omits the file when the
key came from the env — but nothing made sure the env actually carried one.
Provisioning sets it; forgetting it was silent.

validateEnvAtBoot now carries a second kind of check next to the schema: cross-
field rules that only apply with the flag set, for combinations where every value
is individually valid and the result is wrong. Empty without the flag, so an
install that never asked to be centrally administered cannot meet it.

* feat(client): add an attachment point for install-specific screens

Two empty exports on the client, an empty module on the server, and the four
places that read them. Nothing here adds a route, a nav entry or an endpoint —
the arrays are empty and the module contributes nothing, which managed-ext.test
pins so it stays that way.

Read unconditionally, never behind a flag. On the server a conditional import
would tie the module graph to an environment variable, and the graph is built
once per app while the flag is read live; on the client an empty array costs
nothing to spread, so every build walks the same path instead of one build
walking a path nobody tests.

Anything attached here follows the same rules as the rest of src/nest and
src/components: a real module with a controller and a provider, bodies through
the Zod pipe, routes under the global guards, tests.

* test(shared): move the wording gate out of src so shared typechecks again

CI installs shared with its own dependencies only (npm ci --workspace shared),
so there is no @types/node and the `node:fs` import inside src/ failed to
compile. The tests passed the whole time — vitest supplies the module — which is
why this only showed up in the typecheck step.

The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the
same way that one does. It also runs on its own now
(`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names
a line you have to go find.

* test(server): repair the call sites typecheck:tests caught

tests/ sits outside the build tsconfig, so none of this showed up in a green
test run — which is exactly the gap `npm run typecheck:tests` exists to close.
Three kinds, all mine:

- Seven `import type` paths for RuntimeEnvService carried one `../` too many.
  Type-only imports are erased, so vitest never resolved them and nothing failed.
- VersionCheckJob and PluginsController grew a constructor parameter and eleven
  hand-wired `new` calls kept passing the old count. The argument arrived as
  undefined and no assertion happened to read it.
- ConditionContext grew `managed`, and the shared context object in the
  conditions suite did not.

The condition also had no test of its own, so it has one now: both directions,
since the registry uses `is: false` to keep a notice away and a notice that only
belongs on a centrally administered install would use `is: true`.
2026-08-15 16:42:27 +02:00
Maurice bfb3bcd63c feat(config): mark an install as centrally administered
Some installs are run by somebody other than the person who administers them.
The operator owns the credentials, the configuration and the upgrade schedule,
and the admin of the instance is a user of the result rather than the one who
set it up. Nothing in the app knows that today, so it keeps offering settings
whose values are not the admin's to change, and the honest answer to half of
them would be a 403 the UI never anticipated.

TREK_MANAGED says it once. It is a single boolean and the surfaces that care
read it themselves, so adding one never comes back to the config layer.

Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs
token: roughly sixty tests mutate env mid-lifetime, and a value frozen per
built app breaks all of them. `=== true` on the way in, so an unset, empty or
misspelled value means off — a managed install sets it deliberately, and a typo
must not quietly take settings away from an admin who never asked for it.

The guard keys on a decorator, not on a path. MfaPolicyGuard documents where
the two literal path lists it replaced ended up: they stopped being maintained,
and every endpoint added afterwards silently fell on the wrong side. A marker
on the handler cannot drift away from the handler. It carries a mandatory
reason, which is for the next reader of the source rather than for the client,
so the marks stay auditable.

It registers third, after the auth and MFA guards, so a stranger still gets the
401 those two would have given them instead of a 403 that would confirm the
route exists. And it checks the mode before it touches metadata, because it
runs on every request of every install and almost all of them are self-hosted.

One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same
condition and neither can be changed now without hunting for the other.

The flag reaches the client through app-config so the UI can stop offering what
the server would refuse anyway, but it is deliberately not persisted the way
demo_mode is: two installs can share a browser profile, and a stale "this one
is managed" would take settings away on an install that never set it.

No route carries the marker yet. The per-domain marks follow. Unset, which is
every self-hosted install, nothing about this changes anything.
2026-08-15 11:45:11 +02:00
Maurice c9193e0621 fix(maps): answer a keyless place lookup with an empty result, not a 400
An instance without a Google key already runs on the OpenStreetMap stack: search
falls back to Nominatim, autocomplete does too, photos fall back to Wikimedia.
The two details lookups were the exception. Both threw a 400 with "Google Maps
API key not configured" the moment they were handed a Google place id.

A Google id has no OpenStreetMap equivalent, so without a key there really is
nothing to resolve. That is an empty result, not a caller mistake. The callers
already treat a null place as a miss and fall back to the text-search path, so
they were doing the right thing with the wrong signal, at the cost of an error
round-trip and a logged exception each time.

It bites whenever a place that was saved while a key existed is opened after the
key is gone, and on any instance that never had one but imported Google-sourced
places from a shared trip.

The declared return type said the place was non-nullable while the OSM branch
could already return null and the wire contract has always had it nullable. It
now says what it does.
2026-08-14 23:42:51 +02:00
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00
Maurice f23de88b74 fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems()
always applied that rule, but every path that touches one item resolved it by
trip id alone, so trip membership plus packing_edit was enough to reach an item
the member could not see. Update, delete, clone, the contributor routes and the
sharing route all shared the lookup, and the same service methods sit behind the
MCP tools and the plugin RPC, so all three transports carried the gap.

The update was a read as well: it answered with the enriched row, which hands
back the name, category, quantity, weight, the owner's username and the recipient
list of an item the caller was never shown.

Two flows laundered restricted items into places they do not belong. Saving a
trip as a template read every row in the trip, so a template captured the names
and categories of everyone's Personal items. Copying a trip did the same and
re-inserted without is_private/owner_id, so both fell back to the column defaults
and another member's Personal or Shared item reappeared in the copy as a Common
item. That path needs only trip_create and trip access, not even packing_edit.

There is now one visibility rule in the service and every single-item lookup goes
through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry
points, and a missing actor denies rather than falling through unfiltered. An
item the actor may not see is reported as missing, not as forbidden, so the
routes cannot be used to confirm that an id exists.

Two deliberate contract changes fall out of that. The sharing route answers 404
instead of 403 when the caller cannot see the item at all; a visible item they do
not own still answers 403. And a template or a trip copy now carries the Common
list plus the actor's own items, with a restricted item staying restricted and
owned by the copier.

Reported privately by Chichi (@vickyzer027-hash).
2026-08-14 22:09:21 +02:00
Konstantinos Thermos 3e52695124 Merge pull request #1932 from liketrek/feat/comaps-integration
feat(planner): open places and day routes in CoMaps
2026-08-14 19:03:35 +03:00
Maurice 4cab17a6b9 fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to
`passengers` and moved flightReason from the flight onto each passenger. There
is no alias in either direction: a 3.12.0 instance returns `passengers` and
`null` for `seats`, and its save schema accepts only `passengers`.

TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing.
The seat never made it into the import, and because a pull rebuilds the metadata
from the AirTrail payload, flights that had already been imported lost the seat
they had. That is the report. Flights whose sync is switched off were never
re-pulled, which is why theirs survived.

Two more breaks came from the same rename and were not reported. The flight
reason stopped importing for the same reason. And the writeback pushed the seat
under `seats`, which the new schema strips, so a seat edited in TREK was quietly
dropped on the way out; it did not fail loudly because the spread of the fetched
flight carried AirTrail's own `passengers` through untouched.

Both shapes are handled rather than switched between, because instances on 3.11
and older are still out there: reads take `passengers ?? seats`, and the save
body carries the manifest under both keys. Neither schema is strict, so each
version keeps the key it knows and strips the other.

The snapshot hash deliberately keeps feeding the manifest in under the same key.
An unchanged flight on an older instance therefore hashes exactly as before and
does not re-sync, while a 3.12.0 flight hashes differently once and pulls the
missing seat back in. There is a pinned-hash test guarding the first half.

Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0
container, seat imported from each, a seat edited in TREK written back and read
out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0.

Closes #1931
2026-08-14 14:32:35 +02:00
Konstantinos Thermos 0e7c516279 Merge branch 'dev' into feat/comaps-integration
# Conflicts:
#	client/src/components/Planner/DayPlanSidebar.tsx
2026-08-14 15:05:00 +03:00
Maurice 681fe1fee3 fix(dashboard): keep hotels out of the upcoming reservations widget
A stay covers a range rather than a moment, so one week in a hotel would hold a
slot in a six-entry widget for the whole week and push out the bookings that
actually happen on a day. The accommodation belongs to the day plan, which shows
it across its span.

They never worked here either, which is what the report is about. A hotel keeps
its dates on the linked stay, not on the reservation, so the two ways one can
exist behaved differently:

The booking form writes reservation_time = NULL and sends no day_id, so a
form-created hotel matched neither arm of the date test and was silently absent.
That is the "incomplete" half.

The accommodation panel auto-creates its booking with day_id and with the start
day's date stamped into reservation_time. That row did show, but updateAccommodation
syncs only metadata and the confirmation number to its linked bookings, so moving
the stay left the stamp untouched and the widget kept the old date. That is the
"no effect when I change the date" half.

The type test is NULL-safe: the column defaults to 'other' but nothing stops a
NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped
those rows from the widget instead.

Closes #1934
2026-08-14 13:34:13 +02:00
Maurice f43b56a745 fix(collab): let the empty chat state fill the panel
The empty state went in without flex-1 while the message list it stands in for
has it, so it was only as tall as its own content. The composer rode up to sit
directly under the mascot with the rest of the panel left blank, which reads as a
layout that failed rather than a chat waiting for its first message.
2026-08-14 09:53:53 +02:00
Maurice 79c460fd38 fix(map): keep the layer switcher clear of the sidebar edge
At a 12px gap the pill's own shadow ran into the sidebar it sits beside, so the
two read as one surface rather than a control floating on the map. 20px.
2026-08-14 09:53:53 +02:00
Maurice 97c725ea14 feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of
them carried a hover menu of their own. That is five controls' worth of width in
a pane the user can drag narrower, so the exports were the first thing pushed off
the edge, and a hover menu is not reachable at all once its button is gone.

One button opens a dialog instead: the day plan as a PDF, the bookings as a
calendar (a one-off .ics or a live subscription) and the map data as GPX in the
three scopes a device wants. Every row reports that it is working, which the PDF
export needed most: it fetches a 226 kB builder before it renders anything, and
until now the toolbar button simply sat there.

Two strings the old ICS menu had hardcoded in English, the download and the
subscription, come from the locale files the phone export sheet already used.

The toolbar no longer needs the icsHover flag it only held to keep its menu open,
so that comes out of the sidebar's state as well.
2026-08-14 09:53:53 +02:00
Maurice 974c240043 fix(planner): give the reservation time its own chip
The status, the time and the flight or train number sat inside one pill strung
together on a middle dot, and the dot was baked into the translation itself:
planner.resPending ended with " · " in all 23 locales. That made the row read as
one run-on sentence, and it meant the separator could never move without a
locale change.

They are separate chips now, same tint so they still belong together and nowrap
so the time cannot break away from the status it qualifies. The separator is out
of the locale files, which also retires the regex the transport detail modal
needed to strip it back off.
2026-08-14 09:53:53 +02:00
Maurice 460b215e69 fix(collections): name the phone label chip after what it opens
The chip that opens the label manager was labelled with collections.linkLabel,
which is the caption of a link in the list editor. English calls that one "Label"
so it happened to read correctly there, but German got "Bezeichnung", Russian
"Название" and Ukrainian "Підпис": wrong word, and long enough to push the filter
row out of its panel. It uses collections.labels.title now, which is the manager
it opens and is shorter in every locale.

collections.select was the other one over the edge. It is only an aria-label on
the desktop filter bar, so the eight locales whose word did not fit are on a
shorter one now.

The truncate on the chip labels never worked: a flex item keeps min-width:auto,
so the row grew past the panel instead of shrinking its chips. With min-w-0 a
long label name or a bumped-up text size ellipsises inside the panel.
2026-08-14 09:53:53 +02:00
Maurice a234653b29 fix(collections): let the phone dock add a place inside a list
The centre "+" matched /collections exactly, so it only knew it was on the
collections screen while no list was selected. Picking one moves the route to
/collections/:id, the match failed, and the action fell through to the last
branch of useCreateAction, which creates a trip on the dashboard. That is the
state the screen is normally used in, so for most people the button never added
a place at all. It also explains the report that there is no way to add one: the
workaround, adding from "All saved" and assigning afterwards, is the only path
that happened to still work.

The handoff carries the id rather than dropping back to /collections. Otherwise
adding from inside a list would land on the union and the sheet would ask for the
list the user is already looking at.

Closes #1930
2026-08-14 09:53:53 +02:00
Konstantinos Thermos 8065af28ef feat(planner): open places and day routes in CoMaps
Add CoMaps, the offline OpenStreetMap navigator, alongside the existing
map integrations. A geolocated place gains a CoMaps entry in its
navigation menu, and a day's route toolbar gains a button that hands the
day's bookended stops to CoMaps in that day's travel mode.

CoMaps' route link carries only a start and a destination, so a two-stop
day goes as a route and a longer one as named pins rather than silently
losing the stops in between. Links use the https form so a device without
CoMaps installed lands on comaps.at instead of a dead scheme.

The hotel-bookend logic behind the Google Maps export was duplicated
between desktop and mobile; both now share dayExportStops so the two
links cannot disagree about which hotel legs are real.

Addresses discussion #1904
2026-08-14 10:24:14 +03:00
Maurice 041997e5e6 fix(feed): keep the TZID when a clock already carries seconds
fmtDateTime's time-only branch appended '00' for seconds no matter what it was
given. The booking import stores what KItinerary hands it, which is '10:00:00',
so the value came out 17 characters long. dtLine only attaches the TZID to a
well-formed 15-character value, so those events lost their zone and went out as
floating times, which is the #1453 regression by another route.

It pads the way the ISO branch above it already does, so every caller is covered
rather than only the rental markers.
2026-08-13 16:10:25 +02:00
michael-bohr 7d580efce9 feat(feed): car rental pickup and drop-off events in the ICS feed
Rentals now emit two additional timed markers, Pickup and Drop-off, the
same additive shape hotels already get for check-in/check-out (#1721,
last unported piece). The rental's existing single-block event is
untouched.

Sides are derived in priority order: the endpoint role (from = pickup,
to = drop-off, reusing the already-loaded endpoints map); if no
endpoint on the reservation carries either role, positional first/last
by sequence (only once there's more than one endpoint, so a lone
endpoint is never both sides); otherwise reservation_time /
reservation_end_time, since imports frequently carry only that window.
Per side, an event only fires once a usable date and clock resolve;
zone resolution prefers the endpoint's stored timezone, then its
coordinates, then the reservation's linked place.

Follows the hotel `marker` closure's pattern closely (same esc/uid/
dtLine/isDate/isTime/usedZones usage, same Pickup:/Drop-off: summary
convention, same per-kind UID shape) without touching the hotel path
itself.
2026-08-13 16:10:25 +02:00
dependabot[bot] 54a3c8d50c chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17 (#1918)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.14 to 1.19.17.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v1.19.17)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-08-13 16:08:45 +02:00
Maurice 5f4fa30416 docs: say where the outbound proxy switch actually lives
The variables only take effect when Node runs with NODE_USE_ENV_PROXY=1, and
that is set in the image. This page also covers Helm, Unraid and source
installs, where nothing sets it, so following it there changed nothing.

Also recommend NO_PROXY. Without it the proxy sees every request TREK makes to
itself, including the container health check.
2026-08-13 16:07:28 +02:00
Cynosure159 eecde11061 docs(docker): document outbound proxy configuration 2026-08-13 16:07:28 +02:00
Cynosure159 84a84078f6 feat: enable environment proxy support in container 2026-08-13 16:07:28 +02:00
Ida Rogalska 0d737c2466 fix(dayplan): resolve per-leg transit departure time within its own day
Review of the previous commit surfaced that the departure-time lookup keyed a
trip-wide coordinate index, so a POI revisited on another day (a supported
pattern) leaked the other day's time into the prefill. Resolve the departure
time from the leg's own day instead; the name index stays trip-wide (a name is
coordinate-stable).

Also from review: wire the per-leg option on the mobile sidebar too (it was
desktop-only), drop the now-dead route-leg fallback in the outgoing menu opener,
and accept single-digit hours in the time normalisation. Adds a regression test
for the revisited-stop case and pins the hotel-bookend fallback time.
2026-08-13 16:05:20 +02:00
Ida Rogalska ecea39d912 feat(dayplan): offer public transport on any connector leg (#1281 follow-up)
The per-segment travel-mode menu now carries a "Public transit" entry on every
editable connector: stop-to-stop legs and the boundary hotel/booking bookends
alike. Choosing it opens the existing transit search pre-filled with the leg's
origin, destination and the stop's departure time, instead of drawing an OSRM
road route.

A connector only renders when both endpoints are located, so the option is
inherently limited to legs that can actually be searched. A leg's coordinates
are resolved back to named, timed points from the trip's places, accommodations
and booking endpoints. TransitSearchPanel gains an initialTime prop so the
departure time can be seeded.
2026-08-13 16:05:20 +02:00
Maurice 1aa1aea57e ci: retarget Dependabot pull requests onto dev instead of labelling them (#1926)
Dependabot cannot be pointed at dev. \	arget-branch\ in dependabot.yml applies to
version updates only, and security updates always open against the default branch.
Security updates are all this repository gets, since there is no dependabot.yml at
all, so every one of them arrives on main.

The branch check then labelled them wrong-base-branch and close-stale-wrong-branch
shut them 24 hours later. Thirteen of the seventeen Dependabot pull requests died
that way; the four that survived were retargeted by hand. The comment the check
leaves asks the author to click Edit, which a bot cannot do.

So retarget them instead. If that fails, because the branch was cut from main and
dev has moved on, it falls through to the existing path so a human sees it rather
than the check quietly passing.

Note this only takes effect once it is on main: pull_request_target loads the
workflow from the pull request's base branch, and these arrive on main.
2026-08-13 15:39:35 +02:00
Maurice 9dd0a851ca fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)
* fix(mcp): accept per-leg times on create_transport and update_transport

metadata was pinned to Record<string, string>, so a legs array could not get
through and a multi-leg flight built over MCP lost every intermediate time. The
booking ended up with correct endpoints and no legs, which every reader then
treats as a single segment.

legs is now a real, typed parameter with its contract in @trek/shared, shaped
exactly the way the planner form and the importers write metadata.legs. The
folding checks what the readers depend on: one leg fewer than endpoints (the
AirTrail dedupe builds its signatures on that), day ids that belong to the trip,
airport codes that agree with their endpoint, and the times, where a stopover
endpoint carries the departure and the last one the arrival.

metadata keeps its type and its replace semantics. Segments smuggled through
metadata.legs as a JSON string used to be stored and then ignored by everything
that read them; they are refused now and point at the legs parameter instead.

Closes #1914

* fix(atlas): refuse a bucket list entry that is already on the list

Nothing gave a bucket entry an identity, so the same country could be added as
often as it was clicked. A wish is now identified by name, country, target date
and, where it has them, coordinates. A different target date is a different wish
and still lands, which is what the report asked for; two real places sharing a
name stay separate because their coordinates differ.

Empty string and NULL are normalised on both sides of the comparison, otherwise
the dateless case, which is the one that was reported, would slip through.

Deliberately no unique index and no migration. better-sqlite3 is synchronous and
the server is a single process, so the race an index would guard against cannot
happen here, and an index would have needed a destructive dedupe of existing
rows first.

create_bucket_list_item had no target_date parameter at all, so over MCP the
allowed "same place, other date" case was unreachable. It takes one now. The
plugin RPC reports the duplicate as bad params instead of letting it surface as
an internal error.

Closes #1898

* fix(settings): keep a free-form LLM endpoint in admin hands

Any account could name the address the server calls, and the guard in front of
it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a
reasonable trade for whoever runs the instance, because the network is theirs. It
is not one for an arbitrary account, which could aim a request from inside that
network at a target only it chose and read how the target answered.

A free base URL, and the local provider that is one by another name, now come
only from an admin controlled source: the addon config, the instance-wide
defaults, or an admin's own settings. OpenAI and Anthropic stay available to
everyone, because they go to a fixed address with the user's own key.

The resolver is where this is enforced, since it is the single point both the
booking import and the plugin host pass through, and the only one that also
covers values already sitting in the database. The write path refuses as well,
so nothing is stored that would never be read.

The hint text promised something users no longer have, so it changed with it.

Closes #1772

* fix(calendar): span a date-only booking to its end date

A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day
booking that carries an end date still collapsed onto its first day. It gets an
exclusive DTEND now, built the same way the stay branch already builds one. A
booking without an end date serialises byte for byte as before.

While in here: the stays query joins reservations on the accommodation, and two
bookings pointed at the same stay fanned it out into two events sharing one UID.
Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at
random.

The same "at most one booking per stay" assumption sat in the accommodations
service, where it was worse than cosmetic: accommodation_id carries no foreign
key, so deleting a stay took only the first booking and left the second pointing
at something that no longer existed. Both the sync and the delete walk all of
them now. The callers keep reading linkedReservationId; the list is additional.

Refs #1869

* fix(vacay): say that the region is required, because it is

The placeholder called the region optional while the add button stayed disabled
until one was picked. The wording predates multiple calendars per plan, which is
what introduced the gate. Only the value of the existing key changed, in all
locales, so the key set stays identical.

Two things the same screen got wrong were in the way of a clean answer:

The button was clickable while the region list was still loading, which put the
user in exactly the state the wording describes, and it is the state the report
was filed from. It waits for the list now.

Changing the country on an existing calendar kept the previous country's regions
on screen, so a German region could be picked for the Netherlands. The list is
cleared with the country, which only works in combination with the loading gate:
without it, the empty list would happen to enable the button instead.

Closes #1813

* fix(collections): let a saved place's address be corrected

The edit form offered name, category and labels, and the address only on the way
in. The cause was not the form: the update contract never declared address, so
the validation pipe stripped it and the UPDATE had no column for it. Coordinates
were pulled through for #1435 and the address was missed.

It travels the whole chain now, contract to service to both forms. No migration,
the column has always been there.

The mobile add sheet had no address input at all, showing whatever the search
result carried as read-only, so editing would have worked while creating still
would not. It has one.

The MCP tool description had drifted further than the address, never having
mentioned the coordinates either. Both are in it now.

Closes #1870

* fix(collections): keep the place search usable after the first save

The add dialog stays open on purpose so several places can be added in a row, but
after a save it gave no way back into the search. A search with no results was
silent, with no empty state and no toast, so typing another name and pressing
enter looked like nothing happened. The reporter described both halves and it is
the silent search that matches "nothing happens" most closely.

There is an empty state now, using a key that already existed in every locale and
was unused. The search row is sticky inside the dialog body, so it stays reachable
after the save instead of sitting above the scroll position.

Also fixed while in here: refreshing the collection after each add reset the
select mode, the selection and the label filter. In the very flow this dialog is
built for, the user quietly lost all three.

Closes #1921

* fix(vacay): make the phone year grid and the dock button do what they look like

Two separate faults met on this screen.

Tapping a day in the year grid did nothing unless the edit mode was already on,
and the only way into it was a floating button that looks exactly like the one
the dock draws in the same spot. A tap now opens that month in the editor, where
the cells are big enough to hit. At 390px the grid cells are around 21px, which
is too small to be mutating data with in the first place.

The dock's plus was not wired for this route, so it fell through to creating a
trip on the dashboard. Two different actions were stacked in one place, and the
wrong one was on top whenever the screen had not painted yet, which includes the
time its chunk is still loading. The dock leaves the slot to the screen now, and
holds the geometry so the tab groups stay symmetric.

Closes #1811

* fix(budget): settle in whole cents so balances stop drifting

The report is a balance that moves on its own, with nothing added, edited or
removed, on a trip where everything is in euro. Two mechanisms were behind it and
both are real.

The display currency is a fourth value, a per-user setting, so "everything is in
euro" never covered it. Each balance was converted and rounded on its own, and
the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over,
at 0.94 it came out even. That cent appears and disappears with the six hour rate
cache alone, which is exactly the symptom. The flows were rounded separately from
the balances, so the two never had to agree.

And a receipt line nobody was ticked for counted toward the total, and therefore
toward the payer's credit, while landing in no share. That difference is
permanent, and the client sent it that way.

The settlement converts once, at the edge, and does integer arithmetic after
that. Equal splits distribute the credited cents by largest remainder rather than
the entered total, so credits and debits of one expense cancel exactly even in a
foreign currency. Display conversion goes through the same largest-remainder
allocation, so the sum survives it.

With that, the 0.01 epsilon can go. It could never create a cent, but it stranded
them: a one cent balance was filtered out of the flows while staying visible in
the list, which is the "settle up offers nothing" half of the report.

Closes #1382

* fix(journey): reach the book export and the suggestion toggle on a phone

Below 1024px the hero card is dropped, and it held the only way to either action.
The floating bar that replaces it was never given them. It has them now, and the
hero is unmounted rather than hidden, so there is no second invisible copy of the
same controls in the document. The bar's own buttons moved onto the appearance
tokens while they were being touched.

The phone screen never had either action at all and now carries both.

The book itself was hardwired to English, cover and dates included, while the
keys for it had been sitting in every locale unused. It takes the page's
translator and locale, sets the document language, and prints mood and weather,
which were on screen but never in the book.

Closes #1848

* fix(journey): offer the connected photo providers in the phone entry editor

The desktop editor grew an external photo source in #1632; the phone editor that
came out of the mobile rewrite never did, so editing an entry on a phone had no
way to Immich or Synology. It mirrors the desktop mechanics, including the part
that is easy to miss: a save that creates the entry and then fails on the photos
keeps the sheet open, and without remembering what was just created the retry
creates a second entry.

The sheet no longer kicks off its own addon load. The list is loaded once on
boot and this only reacts to it; a second load races the other consumers and
overwrites the store when it lands.

Closes #1808

* fix(mobile): let the document scroll so Safari can collapse its URL bar

iOS Safari only shrinks its address bar when the document scrolls, and TREK's
never did: the root was locked and the mobile shell put a scroll container inside
it. On the bottom tab layout that costs about a sixth of the viewport,
permanently.

Below 768px the document is the scroller again and the full-bleed screens measure
their own height instead of leaning on the shell.

That makes the body scroll locks real for the first time on a phone, and they
were not ready for it. Three of them reset the overflow unconditionally, one of
those on every run of its effect rather than on unmount, so a system notice could
hand the page back while a sheet was still open. They all go through one
reference counted lock now, which is the part that had to land first.

Worth knowing for the issue: this reaches the flow screens. The trip planner, the
journal, the atlas and the vacay screen keep their own full-height scrollers and
still will not collapse the bar.

Closes #1809

* fix(mobile): keep the screen gradient visible behind the translucent cards

The gradient layer introduced with the scroll change sat at z-index -10. A
negative layer paints above the canvas but below the backgrounds of ordinary
blocks, and body carries an opaque one, so the gradient was hidden behind it.
The mobile cards are translucent and take their depth from what is behind them,
so every surface ended up compositing against a single flat colour and the whole
phone UI read as one tone.

The layer is positioned at z-index 0 now and the content above it at 10, which
puts both over body's background. Same visual as before the scroll change, and
the shell still owns no scroll container.

* test(reservations): teach the accommodation delete stub about the id lists

The RPC walks every linked booking since the delete stopped stopping at the
first one. The stub still answered with the two singular fields, so the loop had
nothing to iterate and the cascade events never fired.

* fix(settings): move the free-form LLM endpoint out of user settings entirely

Hiding it from non-admins closed the hole, but it left the option sitting in the
admin's own personal settings, which is not what was asked for and not where it
belongs: an instance has exactly one address it calls out to, and there was a
second, invisible place to set it.

The provider list is now the two hosted ones for everybody, the endpoint field is
gone, and the resolver takes the address from the admin-controlled instance
defaults for every caller including an admin. It no longer reads the users table
at all. The write path refuses the keys for everyone, so nothing can be stored
that would never be read.

The endpoint is configured on the addon in the admin settings, which already
carries provider, model, base URL and key, plus the Ollama model list. Nothing
is lost, it just has one home now.

Behaviour change worth a release note: an admin who set a personal Ollama
endpoint has to move it to the addon config. A non-admin was already in that
position after the previous commit.

Refs #1772

* fix(journey): make the book preview usable on a phone

The preview document carried no viewport, so a phone laid it out at its default
980px while the page is A4 landscape and about 1123px wide. It spilled out of
the frame, which reads as being zoomed far in. Declaring the real width lets the
browser scale the page to fit. Printing is unaffected, it goes by @page.

The preview chrome had the same problem in miniature: a title and two labels on
one row, sized inline, which does not fit a phone and fits translated labels
even less. It moved to a stylesheet, and below 640px the card goes full-bleed,
the decorative title steps aside and the two actions share the width.

Refs #1848

* fix(mobile): stop the shell trapping the screens' own overlays

Lifting the background layer into the positioned range meant the content wrapper
had to be positioned too, and a positioned wrapper with a z-index is a stacking
context. Every fixed overlay a screen renders inside it got clamped to that
level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at
z-40, and it disappeared behind it.

The layer goes back to a negative z-index and the wrapper back to no positioning
at all. What it needed in the first place was for body to stop painting over it,
which is one line in the phone media block rather than a new stacking context.

* fix(mobile): let the phone background override actually win

A media query adds no specificity, so the themed body rule further down the file
kept winning on source order and painted over the shell's background layer. The
override is on html body now.

The gradient was back to being invisible, which is the flat single-tone phone UI
again.

* fix(vacay): draw today's ring around the day instead of replacing it

On the phone, today short-circuited the day-cell matrix and returned a ring on an
empty cell. A vacation day or a company holiday logged for today was stored and
counted in the totals, but the cell showed nothing, so it read as if the tap had
not taken.

Today is an annotation now, layered like the school band and the shared-calendar
rings, which is how the desktop month card has always treated it. The strong ink
stays only where the cell has no fill of its own, since --m-ink is light in dark
mode and would disappear on a pastel.

* fix(map): stop serving a stale GL style document from the service worker

The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the
style document lives there too. Under StaleWhileRevalidate the map was built
from the previous revision on every load, with the refresh landing one load too
late, so a style republished in Studio with different label languages kept
rendering the old ones. The network tab showed a 200 throughout, because the
service worker's own answer is a real 200, which is why this looked like the app
rewriting text-field at runtime.

The style now has its own rule ahead of the tile rules, on NetworkFirst so it is
current whenever the network is, and still cached so the map opens offline. Its
own small cache too: sharing an LRU with three thousand tiles made the staleness
come and go at random. OpenFreeMap styles had the same problem and are covered
by the same rule.

Closes #1924
2026-08-13 14:06:35 +02:00
Maurice 3c5e406229 feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do

Recording what a museum ticket or a guided tour cost meant leaving the place,
opening the Costs tab and typing it in again — while a booking has had a Costs
block in its own form all along.

The place form now carries that block. Same component, same flow: with nothing
linked it offers Create expense, which saves the place and then opens the Costs
editor for a new expense pointing at it; once linked it shows the expense with
edit and remove. budget_items gains place_id beside the reservation_id it
already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop
and phone both, and the mobile editor carries the link through its own save path
rather than the desktop one.

The expense belongs to the place, not to a day assignment, so a place sitting on
five days is not five tickets. Hanging it off the assignment would have meant
adding a place to three days quietly creating three expenses and removing a day
quietly deleting one.

Deleting a place takes its expense with it in the same transaction and announces
it, on every path that deletes a place: REST, both MCP tools and the plugin RPC.

Closes #1298

* test(places): teach the RPC stubs about linkedExpenseIds

The delete paths read the linked expense before the place is gone, so the two
hand-built PlacesService stubs threw on the new method instead of reaching the
hook, the remove and the broadcast — four failures out of one missing line.
2026-08-12 21:37:12 +02:00
Konstantinos Thermos e59948e707 Merge pull request #1913 from liketrek/fix/atlas-bucket-search-dropdown-clipped
fix(atlas): stop the panel clipping the top of the bucket search results
2026-08-12 18:53:35 +03:00
Maurice df3edac7b9 feat: six community requests — import dedup, accommodations in the feed, OAuth refresh race, visited-from-trip, journal jumps, phone packing rows (#1915)
* fix(places): match imports on the provider id so renamed places are not duplicated

Re-importing a Google Maps list duplicated every place the user had renamed.
buildDedupSet only ever collected names and, for unnamed places, coordinates,
and isPlaceDuplicate returns on the name before it reaches the coordinate
fallback, so a renamed place was findable by neither. The ids were stored all
along, and even backfilled onto matched places, they were simply never read.

Match on google_place_id / google_ftid / osm_id first, keeping name and
coordinates in their old roles below it. Widening the coordinate check instead
would have merged the restaurant and the bar in the same building.

The collection copy carried a hand-mirrored port of the same predicate; it now
uses the shared helpers and gets the fix with them.

Closes #1550

* feat(calendar): put accommodations into the subscribed feed as all-day stays

A hotel keeps its dates on the linked stay, not on the reservation: the booking
form writes reservation_time = NULL for type 'hotel', and day_accommodations
carries start day, end day and the check-in/out clock. The feed only ever read
the reservation, so a stay showed up once on the arrival day at best, and not at
all when it came from the booking form.

Join the stay in and emit the reservation as an all-day range over every night,
which also keeps it correct when days are reordered later — the dates now come
from the day rows rather than from the copy stamped at creation time. Check-in
and check-out become their own timed events when the stay records a clock,
because an all-day event cannot carry the time you actually need reminding of.
The check-in window, when set, becomes that event's DTEND.

Closes #1586

* fix(oauth): treat a concurrent refresh as a race, not as a stolen token

Refresh rotation is a race by construction. Several MCP clients share one
refresh token, both post it within the same second, and the loser was read as a
replay: chain revoked, MCP sessions torn down, browser window asking to log in
again. That is the daily re-authentication people were seeing, not a short
lifetime — access tokens last an hour and refresh tokens roll for thirty days.

A revoked refresh token now only counts as a replay once it is outside a
30-second grace window, and only when the rotation that revoked it left a
successor still alive. Inside the window the caller gets its own pair branched
off the same parent, so both clients walk away with a working token. Explicit
revocation leaves no live successor and a chain killed by a real replay has none
either, so neither can slip through the window.

Closes #1007

* feat(collections): mark a place visited from the trip it belongs to

A place saved in several lists had to be opened in each of them to say you had
been there, which is the wrong way round: you find out you have been somewhere
while looking at the trip, not while browsing the library.

The save picker now shows each list's own status and cycles it in place, with one
action for "visited, wherever this is saved". The planner's selection bar and the
mobile bulk toolbar carry the same thing for a whole selection — select all, one
tap, done. Matching happens on the server with the signals findMembership already
uses plus the source link a place saved from this trip carries, so a copy renamed
inside a list is still found. Lists you may only read are skipped rather than
refused; a shared list you cannot edit should not stop you marking your own.

Closes #1469

* feat(journey): jump to the top or back to the last entry in a long journal

Everything that adds to a journal sits at the top — the entry button, the
gallery upload — and where you left off sits at the bottom, so adding one photo
to a long journey meant scrolling the whole way twice.

Two buttons float over the middle of the feed, clear of the Add Entry column on
one side and the reorder arrows on the other, each appearing only when there is
more than a screenful to travel in that direction. They live in a zero-height
sticky box so they take no layout space and never push the timeline around.
While in the header, the status pill also picks up the height of the round
buttons beside it.

Closes #1088

* fix(packing): give the item name the row back on phones

An item name had roughly a third of the row to itself: a spelled-out sharing
sentence, an empty weight column and two round action buttons took the rest, so
anything longer than a word was cut off.

Sharing is now the icon it always was next to a label rather than a sentence in
the layout, an unset weight prints nothing instead of a dash, and edit/delete
sit behind one overflow button that opens the same inline menu the bag picker
already uses. The to-do list needed none of this — it wraps its chips onto a
second line already.

Closes #1525

* docs(wiki): cover the six community changes

- Calendar feeds: accommodations as an all-day range plus check-in/check-out
- Collections: setting the visited status from a trip, and how places are matched
- Journey: the jump buttons on a long journal
- Packing: what the phone row keeps and what moved behind the overflow button
- Places: re-importing a list no longer duplicates renamed places
- MCP setup: the rotation race no longer ends an OAuth session

* test(collections): assert the stored status instead of a private method

tests/ sits outside the build tsconfig, so reaching into getPlaceById only
showed up in the dedicated typecheck step. Reading the row is the better
assertion anyway — it pins what was persisted, not what a getter returns.
2026-08-12 17:43:22 +02:00
Konstantinos Thermos 85e820dee5 fix(atlas): stop the panel clipping the top of the bucket search results
The bucket-list place search opened its result list upward with
position:absolute inside the desktop Atlas panel, which is overflow-hidden
for its rounded corners. The panel leaves ~125px above the input, so the
top of the 160px list was painted outside the clip box — the first result
was invisible and unreachable, since that is the container's own top and
not a scroll offset.

Portal the list to the body and position it fixed off the input row,
flipping up or down by available room and capping its height to that room.
This matches CustomSelect, whose month/year dropdowns already escape the
same panel this way.

Fixes #1899
2026-08-12 17:31:35 +03:00
Konstantinos Thermos 813b9886b3 fix(vacay): let a day stranded by a weekend-config change be cleared (#1912)
Clicking a logged day that a weekend-days change had turned into a weekend
day was swallowed by the client, while the entry kept counting against the
entitlement. The server already allows the removal (only conversions and new
inserts are refused on a blocked day), so mirror that: a blocked weekend cell
holding the acting person's entry stays clickable and clears it with the
entry's own fraction/kind. Logging a new weekend day stays blocked.

Fixes #1897
2026-08-12 16:22:04 +02:00
Maurice 870bd6131f feat(journey): draw the trips' GPX tracks on the journey map (#1911)
* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
2026-08-12 15:14:50 +02:00
Maurice 767dc662b7 feat(atlas): search by place, and count cities that are actually cities (#1908)
* refactor(atlas): drop the last-trip tile from the stats bar

The row beside the continent counts is statistics; a single trip sitting in it
was neither, and the trip it named is already one click away in the country
detail below. Its two locale keys go with it, along with atlas.lastVisitLabel,
which nothing had rendered for a while.

* feat(atlas): resolve a coordinate to its country and region

Groundwork for searching the atlas by place: /locate answers with the admin1
region a point falls in, resolved against the same bundled polygons the visited
colouring uses, so the answer is always something the map can highlight. A
country without admin1 coverage resolves to a country and a null region, which
is a real answer rather than a failure.

* feat(atlas): search the map by place, not only by country

Typing a city geocodes it through the same /maps/search everything else uses and
offers the hits below the instant local country matches. Picking one flies there
and opens the dialog for the region it sits in, so Milan marks Lombardy without
the user knowing that is where Milan is.

The country list stays offline and instant; only the place lookup waits on the
network, debounced and sequence-guarded so a slow answer cannot overwrite a
newer query.

* fix(atlas): count cities instead of shop names

The visited-cities figure scanned the address left to right and took the first
part that was not a country and matched a latin-only pattern. The first part of
a geocoded address is the place itself, so a trip to Tokyo counted Shibuya Sky,
Shibuya Parco and Family Mart as three cities, while an address written in
Japanese, Cyrillic, Greek or Korean counted none at all.

Read from the administrative end instead, skipping the country, anything with
digits, and the region already resolved for that place in place_regions, which
is what separates the city from the region above it. On one real trip the count
went from 20 entries, nearly all of them streets and shops, to Paris, Shibuya,
Minato and three Berlin boroughs.

* i18n(atlas): translate the place-search heading

* docs(atlas): document place search and how cities are counted

* fix(atlas): stop the search panel closing itself while results arrive

The dropdown closed on mouseleave, which was survivable while it held only local
country matches and appeared complete in one go. Geocoded places land a moment
later and grow the list under a cursor already resting there, so the panel shut
itself seconds after opening. Closes on a click outside now, Escape unchanged.
2026-08-12 14:14:37 +02:00
Maurice 6e10ffa75d test(mobile): follow the export sheet's rename (#1909)
Two suites under client/tests/ still looked for "Export Calendar" by accessible
name. They live outside src/, which is why the targeted runs while building this
missed them and only CI caught it.
2026-08-12 14:02:39 +02:00
Maurice 9ce6eed9ad feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX

TREK reads GPX but never wrote it, so a planned trip could not reach the offline
map or the handheld it was planned for. The importer already fixes the mapping:
a wpt is a place, a rte or trk is a place with route_geometry, so writing is that
reversed. Days have no import counterpart and become routes of their stops in
order, which is the part that puts a plan on a device.

XMLBuilder comes from the same fast-xml-parser the importer parses with, so
nothing new is pulled in.

* feat(dayplan): a GPX button beside PDF and ICS

Scopes are worded by what someone wants on their device rather than by the GPX
element: the whole trip, just the places for an offline map, just the days to
follow a plan. An empty trip says so instead of reporting a failed download.

* feat(mobile): GPX in the export sheet, which is no longer calendar-only

The sheet held the PDF export next to the calendar entries and was still called
Export Calendar; with GPX beside them the name was simply wrong.

* i18n(dayplan): translate the GPX export into all locales

* docs(map): document the GPX export

* fix(dayplan): drop the GPX tooltip and centre both export menus

The tooltip sat under the cursor exactly where the menu opens, so hovering the
button produced both at once. The label lives on as an aria-label. Both menus
now open centred under their button instead of hanging off its right edge.
2026-08-12 12:45:29 +02:00
Maurice b842d74a5d feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
2026-08-12 11:54:36 +02:00
Mauro Remondi b0fd73681b feat(atlas): show wishlist countries as hatched, stable colors, remove action (#1893)
* feat(atlas): show wishlist countries as hatched, stable colors, remove action

* i18n(atlas): translate the wishlist removal keys into all locales

* feat(atlas): offer the wishlist removal in the mobile country sheet

* docs(atlas): document the wishlist hatch and the removal action

* test(atlas): assert the stable country colour instead of a palette literal

---------

Co-authored-by: Mauro Remondi <mauro.remondi@sourcesense.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-12 09:49:38 +02:00
Maurice 27ffd3ec68 fix(db): append the incoming leg mode migration so upgrades pick it up 2026-08-12 09:08:47 +02:00
Ida Rogalska 15c8675959 client: omit default outgoing direction from assignment transport payload
The per-segment transport request now carries a direction, but the server
schema defaults it to 'outgoing'. Only send direction for the incoming
boundary-leg case so the outgoing payload stays identical to the pre-#1281
shape and existing callers keep the same wire format.
2026-08-12 09:08:47 +02:00
Ida Rogalska e5c885e768 client: target the real bookend endpoint from hotel/booking mode menus 2026-08-12 09:08:47 +02:00
Ida Rogalska f5e06e0dea test: outgoing and incoming leg modes persist independently 2026-08-12 09:08:47 +02:00
Ida Rogalska 8c3a235c3b client: editable travel mode on booking and hotel-bookend connectors 2026-08-12 09:08:47 +02:00
Ida Rogalska 38bdb1d4b7 client: resolve boundary-leg modes from either endpoint in the map route calc 2026-08-12 09:08:47 +02:00
Ida Rogalska 5679ea76d2 client: resolve boundary-leg modes from either endpoint in the day sidebar 2026-08-12 09:08:47 +02:00
Ida Rogalska 899316d5f8 client: add resolveLegMode helper for endpoint-aware leg modes 2026-08-12 09:08:47 +02:00
Ida Rogalska cedbe39b35 api: PUT /assignments/:id/transport gains direction (outgoing|incoming) 2026-08-12 09:08:47 +02:00
Ida Rogalska edb21b681f read: project incoming_leg_transport_mode via rowShape + getAssignmentWithPlace 2026-08-12 09:08:47 +02:00
Ida Rogalska bb6b946868 types: incoming_leg_transport_mode on assignment schema + rows 2026-08-12 09:08:47 +02:00
Ida Rogalska a17bfdb1a1 db: add incoming_leg_transport_mode to day_assignments 2026-08-12 09:08:47 +02:00
jubnl ac42cc941b Merge remote-tracking branch 'origin/dev' into dev 2026-08-12 00:39:00 +02:00
jubnl 7e7277beb0 fix(vacay): enforce weekend blocking in toggleEntry service
With block_weekends on, toggling a weekend day via MCP or the plugin RPC
still added the entry and burned allowance — the guard lived only in the
web UI (mcp-smoke-test I-02). toggleEntry now rejects inserts and
in-place conversions on blocked days with { error: 'weekend_blocked' }
(the votePoll pattern); removing an existing entry stays allowed so
stray weekend entries remain cleanable. The controller maps the code to
a 400, the MCP tool to an isError result.
2026-08-12 00:38:41 +02:00
jubnl 705c3c08dd fix(trips): remap cross-links and copy split rows when duplicating a trip
Copying a trip nulled budget->reservation and reservation->accommodation
links (TEXT accommodation_id vs number-keyed map) and dropped
budget_item_members/payers, assignment_participants, budget currency/
exchange_rate/expense_date/ticket_json and reservation url. All links now
remap to the copied rows; external_* sync identity is intentionally not
copied; the to-do reset is now documented (smoke-test I-01).
2026-08-12 00:22:32 +02:00
Maurice 62bc7ff8fd Jump to today, map-app links, drag to day, PDF landing time (#1891)
* Open a running trip on today

Asked for in discussion #1567: during a trip, opening the planner starts
at day 1 and you scroll to find out what is planned for today.

The mobile shell has done this since the plan tab was built - it seeds
the active day with today when the trip is running. The desktop day plan
never did, so the same trip behaved differently depending on the screen
you opened it on. It now selects today's day, expands it and scrolls it
into view, once per mount and only while nothing is selected yet, so a
deep link or a day picked before switching tabs still wins.

The date comparison moved into one helper both sides use. It is
deliberately local rather than toISOString(): 08:00 in Tokyo is still
yesterday in UTC, and a trip day is a calendar day where the traveller
is standing.

Also fixes a note losing its colour when dragged to another day. A move
is a delete plus a create, and the create was still sending the three
fields notes had before colours existed.

* Open a collection place in a map app

Asked for in discussion #1455: a saved place is somewhere you intend to
go, but the only way to get directions to one was to add it to a trip
first, where the button has been for a while.

The collection detail gets the same picker off the same helper: Google
Maps, Waze, Apple Maps and OpenStreetMap, filtered by what the place can
actually be located with. A place with coordinates offers all of them; a
place typed in by hand with just a name and an address still offers the
two that take a search string, and a place with neither shows no button
at all.

It sits on the meta line beside the address, quieter than the link chips
below it, because it is an action on the place rather than a piece of it.

* Drag a place off the map onto a day

Asked for in discussion #891: assigning a place means finding it again in
the sidebar, even though it is right there under the cursor on the map.

The drop half already existed and did not need touching. Every day, every
row and every gap between rows in the day plan reads a placeId and calls
onAssignToDay with the position it was dropped at, complete with the
toast and the undo entry. What was missing was a second place to pick a
place UP from - the only one was the row in the places sidebar.

Markers become that second source. Both renderers build their markers as
real DOM nodes, so one helper covers Leaflet and the GL variants alike,
and it sets the same two channels the sidebar row does: dataTransfer for
the drop, and window.__dragData because browsers hide dataTransfer while
the pointer is merely hovering. The day plan cannot tell the two apart.

Not offered on touch: HTML5 drag is a pointer feature, and on a phone
the day plan is not on screen next to the map anyway.

* Show the landing time in the PDF

Asked for in discussion #1310: the export prints a flight's departure and
nothing else, so a reader cannot tell how much of the day is left for
anything after it.

The day plan has drawn both times as "14:30 - 16:45" for a while; the
export never got the rule. Now it does, for any transport that begins and
ends on the same day - a train and a ferry read the same way a flight
does.

Across a span it stays as it was: the arrival belongs to the arrival day,
which already prints it as its own time, and repeating it on the
departure day would put tomorrow's clock next to today's departure.
2026-08-11 23:26:58 +02:00
jubnl 92b9e7548e Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 23:26:36 +02:00
jubnl 04812702ec docs: close the bridge chapter 2026-08-11 23:25:22 +02:00
jubnl a01476f445 feat(mcp): audit MCP tool calls at the registry seam 2026-08-11 23:19:39 +02:00
jubnl 1239be4259 refactor(server): fold the _shared MCP helpers into an injectable guards provider; delete the last bridge 2026-08-11 23:11:34 +02:00
jubnl 954a3ca0a7 refactor(server): serve /mcp from a Nest controller; delete auth, audit, addons and oauth bridges 2026-08-11 23:01:19 +02:00
jubnl fac442086f refactor(server): providerize the OAuth SDK adapter behind the container 2026-08-11 22:44:20 +02:00
jubnl 1390bfbe06 refactor(server): move health, discovery and consent-COOP behind the container 2026-08-11 22:23:51 +02:00
Maurice ce37e8e97c Rich formatting and colours for notes (#1889)
* Rich formatting and colours for notes

Asked for in discussion #1629: notes should carry formatting, links and
colour, so a warning reads differently from a recommendation.

Notes have rendered Markdown for a long time. The problem was that the
only way to write it was a bare textarea, so unless you already knew the
syntax there was nothing to tell you it existed. Both note fields get a
formatting bar - bold, italic, strikethrough, code, link, lists, quote -
which edits Markdown rather than replacing it with a rich-text model, so
the stored value stays plain text that the PDF, the plugin API and every
existing note keep reading unchanged.

Links now open in their own tab. A bare anchor navigates the planner
away, which loses the open day and the map, and without rel=noopener the
opened page can navigate this one - notes are content other trip members
wrote.

Day notes get a colour from a short shared palette. It tints the card,
the border and the icon circle, and it reaches the PDF as the spine and
rule rather than a fill. The dialog is two columns now, with a live
preview of the card beside the picker, because a row of swatches says
nothing about what a tinted note looks like next to a place.

On the phone the thirty-two icon grid was most of the screen, so it is
folded behind the chosen icon, which doubles as the colour preview. The
detail field there got the same formatting bar and the same 2000
character ceiling as the desktop one - they write the same column.

* Fix the tests that pinned the old note caps, and rework the note actions

The e2e suite builds its own day_notes table by hand rather than going
through schema.ts, so it never got the new column and every note insert
came back 500. Three more tests wrote down the 250 character body cap
and the old create() arity.

While in there: the edit and delete buttons on a note card were a 10px
icon in a 14px box, side by side, and they took width away from the text
on every note whether you were hovering or not. They are stacked in a
capsule now, 30px targets with their own hover states, floating over the
right edge of the card only while the pointer is on it. The text keeps
the full width the rest of the time.

* Centre the day badge when there is no weather to stack under it

The badge is pinned to the top of the row because with weather it is a
tall stack that has to start there. Without a location there is no
weather block, so all that is left is a 26px circle floating above the
day title.

* Render the place subtitle in the places list, and drop the form hint

The row in the places sidebar printed the description raw while the same
line in the day plan renders it, so a place written with the formatting
bar read as _underscores_ in one list and italics in the other. Still
clamped to one line - it is a list entry, not the inspector.

The "Markdown supported" line under the notes field goes: the bar above
it already says so.
2026-08-11 22:14:12 +02:00
jubnl a5202f418b Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 21:15:40 +02:00
jubnl f842073351 refactor(server): delete airtrail.bridge and notifications.instance via a core/pull split 2026-08-11 21:13:32 +02:00
jubnl c1819c7ab5 refactor(server): delete assignments.bridge via an assignments-domain module split 2026-08-11 20:58:37 +02:00
jubnl 079064fdcd refactor(server): delete trips.bridge via leaf membership reads and prompt relocation 2026-08-11 20:28:49 +02:00
Maurice 435b782c85 Add a note field to expenses (#1888)
Requested in discussion #1658: an expense carries an amount and a
category, and everything else about it ends up crammed into the title.

The column was already there. It just was not available, because the
costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an
expense split by receipt could never carry a note - and saving one any
other way wrote note=null, which quietly wiped whatever had been typed
into the budget table. The receipt gets its own column and note goes
back to being text. Old clients that still send the blob as a note are
routed to the right column instead of clobbering the note.

On desktop the row now has fixed columns, so the amount does not drift
left when one row has a note and the next does not. The category moved
to the card edge as a tab like the mobile card does, which is what frees
the line the note sits on. Collapsed it is one line, and the same
element grows in place when opened.

The dialog is two columns now: what the expense was on the left, how it
is shared on the right, grouped into panels the way the trip dialog
does it.

On the phone the eleven category pills became a dropdown, which is where
the room for the note field came from, and a note on a card unfolds when
you tap it.
2026-08-11 18:13:07 +02:00
Maurice a39c31cb48 Hide permanently closed places from search (#1887)
A place that has shut down for good is never the answer to "where should
we go", so it should not be in the results at all.

Google knows this and says so in businessStatus, we just never asked for
the field. It goes into the field mask and CLOSED_PERMANENTLY drops out.
CLOSED_TEMPORARILY stays - a restaurant on holiday next month is still
worth planning around - and results without the field are parks and
viewpoints rather than businesses, so they stay too.

OSM states it a few different ways. Re-tagged objects (disused:amenity,
abandoned:shop) never matched the selectors to begin with, but plenty
keep their original tag and gain a marker instead, and those did come
back. Same for opening_hours=closed/off.
2026-08-11 17:04:09 +02:00
jubnl e1162c1c07 docs: bring the bridge docs in line
Roadmap census 14 -> 8 (5 seam-pinned, 3 verified-permanent), resources.ts DoD line closed, README in-container list and counts corrected. Includes the SystemNoticesService unit test the src/nest coverage ratchet demanded after the addonEnabled threading.
2026-08-11 17:00:05 +02:00
Maurice 6fe89588c2 feat(plugins): give plugins the surface, insets and mobile palette they need (#1886)
* Tell a plugin where it is mounted

A plugin sits in one of six host surfaces and cannot see which: a trip tab and
the plugin page fill their container and scroll themselves, a dashboard widget
and the settings frame report their height, a detail slot is a narrow column in
someone else's panel. The contracts differ, and on a filling surface the height
report is discarded, so a plugin cannot even discover the difference by trying.

trek:context now carries viewport: the surface by name, phone or desktop, the
fill flag, and the insets the host already keeps clear. The insets describe
space that is held free, not padding to add — the mobile trip tab is the only
surface with chrome floating over it, and the numbers come from the same
variables the native tabs use so the two cannot drift.

* Seat the plugin tab like a native one

The mobile plugin tab kept clearance at the bottom and none at the top, so the
plugin's first row sat under the status bar and behind the floating back button.
The bottom value was a copied 84 that ignored the safe area on the devices that
have one, and the padding sat on the container rather than in the scroller.

Both clearances now come from the variables TabScroller uses, so the tab cannot
drift away from the native ones. The frame also states its surface, and pins
color-scheme for the reason both settings mounts already pin it: Chromium paints
a white canvas behind a transparent frame otherwise.

The tab also had no name anywhere on screen. It is not in the dock, so nothing
lit up there either, and the header had a branch for every other tab. It gets
the same treatment, built from the tab entry the planner already assembles.

* Keep the widget card intact on the phone

The card is built from the dashboard's glass vocabulary, which is scoped to
.trek-dash. The phone dashboard is a different route and mounts these widgets
outside that subtree, so background, border, shadow and blur all resolved empty
and the widget stood on the gradient as a bare title over an iframe. Each value
now falls back to the mobile token the surrounding cards are made of.

* Give the design kit a mobile layer

The kit was written for the desktop and stayed there: a filled card with a drop
shadow where the mobile design is translucent and border-led, inputs at 13px
which make iOS Safari zoom on focus and never zoom back, feedback on :hover
which never fires on a touchscreen, and one media query in the whole stylesheet.

It reads the new viewport field now and sets data-form-factor, data-surface and
data-fill on the document, plus the insets as variables. Everything mobile hangs
off data-form-factor="phone", so nothing changes on the desktop: the card loses
its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to
16px, and pressing something scales it instead of waiting for a hover that will
not come. A filling surface anchors the body and scrolls an inner element,
because its height report is discarded by the host.

Plus the pieces a v4 screen is actually made of, so an author does not have to
reverse-engineer them: eyebrow, count pill, section header, status dot, round
icon button, field, segmented control. Each falls back to a global token where
the mobile family is absent, so they work on both.

* Document what a plugin needs on a phone

The guide had nothing on the subject: no mention of the second token family, of
who scrolls, of what sizes to expect, or that the kit brings almost nothing
responsive. It also promised more than it delivered, claiming the palette was
complete and that applying it matches the host "exactly" — fonts are named, not
shipped, so that was never true.

The new section covers the six surfaces and which of them fill versus report a
height, what insets mean (space already held free, not padding to add), the
mobile palette with the branch-on-absence pattern, and the four rules that
decide whether it feels native. It also says plainly that the dev preview cannot
show a phone width yet.
2026-08-11 16:51:02 +02:00
jubnl 1a945baebe refactor(server): shrink assignments.bridge to its two live exports 2026-08-11 16:46:52 +02:00
jubnl 8b7b91d814 refactor(server): let user-cleanup inject BudgetService and delete budget.bridge 2026-08-11 16:42:49 +02:00
jubnl 2b2c6462ec refactor(server): fold the in-container bridge edges 2026-08-11 16:32:02 +02:00
jubnl 0718bd1b0c refactor(server): shrink the seam bridges to their live exports 2026-08-11 16:23:46 +02:00
jubnl 7880eba6fb refactor(server): move the journey resources onto the registry and retire src/mcp/resources.ts 2026-08-11 16:15:12 +02:00
Maurice d1e3d8a75a feat(places): offer Waze and Apple Maps beside Google Maps (#1885)
* Offer Waze and Apple Maps beside Google Maps

A place could only be opened in Google Maps, and the request in #919 was for the
apps people actually drive with. Google keeps the precise link it always had:
ftid, then place id, then the details URL, and coordinates only as a last resort,
so it still lands on the entry inside a mall rather than on the roof. Waze and
Apple Maps take coordinates and nothing else, which is why they are absent for a
place with no position, and why they open with navigation armed instead of on a
place view.

No setting. Every app that can resolve the place is offered, except Apple Maps,
which is dead weight on Android and Windows and therefore only appears on Apple
platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the
app, so the check does not need to tell them apart.

The picker is a new component rather than the context menu, which has no notion
of a submenu. It is portalled because both call sites sit in panels that clip
their overflow, and it is measured before it is painted: near the bottom of the
window it flips above the trigger instead of running off the screen, and it is
pulled back inside when it would overflow to the right. With a single app left
to offer there is no menu at all, the button opens it directly, exactly as it
behaved before.

The three surfaces take it the way each has room for. The inspector button
collects them behind one entry, the day-plan context menu lists them flat
because a menu can grow, and the phone keeps its single circle and opens the
picker as a sheet.

* Move OpenStreetMap into the navigation menu, and name the destination

OSM was still a button of its own beside the one that now collects the map apps,
which is the arrangement the menu exists to end. It belongs with the others.

The bigger correction is what Waze and Apple Maps are handed. Both document a
query parameter next to the position, and the pair is what makes a destination
legible: coordinates say which place is meant, the name is what the driver reads
instead of a number. Neither gets a name without a position, because there are a
lot of places called "Bahnhof". Google is unchanged; it can already do better
than a name through its place id.

* Stop drawing the section count as a pill inside a pill

SectionHeader wraps its count in a CountPill, and all three callers passed one
in already, so every section header carried two stacked pills: a lighter
rounded background peeking out from behind the real one. Pass the number and
let the header do the wrapping.

* Give the traveler filter a label and a way back

The avatars floated between the toolbar and the first section with nothing to
say what they were, and once a filter was on the only way out was remembering
which face had been tapped. It is a labelled bar now, closed off by the same
divider the rows below use, with an explicit reset that appears while a filter
is active. Both texts already exist in all 23 languages.

* Line the traveler filter up with the section titles

It carried the sheet's usual 18px of horizontal padding while SectionHeader
right below it starts at 2, so the two labels sat on different left edges.

* Filter the place list by stars instead of sorting by them

Sorting put the best-rated place first and left every other one on the list,
which does not help when the question is "what does the group actually want to
do". It is a floor now, the same one the collections bar uses: All, or 5+ down
to 1+, with unrated places dropping out once a floor is set. The button carries
the active floor so the list is never quietly shortened.

The row it sits in used to render only when the trip had categories, which hid
the star control on a fresh trip. That is exactly where someone collects places
to rate, so the row is always there now and only the category dropdown depends
on there being categories.

places.sortByRating is replaced by places.filterByRating in all 23 languages,
since the old label described a behaviour that no longer exists.

* Keep the collections map when a filter matches nothing

Clicking a label with no places behind it made the map disappear and the page
reflow to full width. The layout hung off the FILTERED set: no visible places
meant nothing mappable meant no split. A filter should empty the map, not tear
it out, so the layout now asks whether the list has anything mappable at all,
while the markers keep following the filter.

* Give the label filters a tray of their own

Two changes to the filter row. Select and manage-labels drop their captions and
keep icon, title and aria-label, which puts them on the same footing as the
add-place button that was already icon-only.

The labels themselves were pill-shaped buttons with their own borders sitting
loose among square-cornered dropdowns, so a list with a handful of them read as
a pile rather than as one control. They share the filter buttons' radius and
type now, and sit in a recessed strip led by a tag glyph: the chips carry no
border of their own, because a frame inside a frame was half the noise. Manage
stays quiet at the end of the strip until hovered.

* Put the collections controls on one line

The filter row sat at about 31px while the buttons floating over the map are
38, and the icon-only add button was the odd one out even within its own row.
Scrolled to the top, where the row and the map's top bar are side by side, that
reads as three different heights.

One --col-ctl-h for both, fixed rather than derived from padding so a button
with an icon and one with a word cannot drift apart, and the row starts at the
same offset the map's own bar does. Phones raise the variable instead of
patching individual min-heights, which keeps the square buttons square.

* Offer Apple Maps on desktops that are not Apple

Apple Maps has had a web version since 2024, so the link resolves to a usable
map on Windows and Linux rather than to nothing. Hiding it there was a guess
about a limitation that no longer exists.

Android keeps the exemption: the web version works there too, but nobody
navigating from an Android phone reaches for Apple Maps, and the row of choices
is short on purpose.

* Move the label filter into the map's top bar

The filter row was running out of room while the map's own bar had space to
spare, so the labels ride up there next to the collapse button, in the same
glass as the controls beside them and scrolling sideways rather than wrapping.

They fall back into the filter row whenever no map is on screen — a narrow
window in list view, or a list with nothing mappable in it — so they can never
end up somewhere the user cannot reach. Both places render the same component
now instead of two copies drifting apart.

* Stop the active select button drawing a dark icon on a dark fill

Two rules with the same specificity claimed the same class. On a dropdown
`.open` means the panel is down, so the label stays dark; on the select button
it meant the mode is on, which fills it with the accent and needs the light
text. The dropdown rule sits later in the file, so it won, and the icon
disappeared into the fill the moment the button was clicked.

The state is `.on` now, which is what the other toggles on this page
(col-map-btn, col-iconbtn) already call it.

* Open the place in Google Maps, not the coordinates

Reported in #1278 and still reproducible: without a Google identifier the link
carried nothing but latitude and longitude, so Maps dropped an unlabelled pin
instead of opening the entry. The name and the address were both on hand and
both thrown away.

Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is
what the fallback sends now. A name with no address still falls through to
coordinates, because "Central Station" on its own would confidently open the
wrong city, and a pin in the right spot beats a link to the wrong place.

The precise paths are untouched: an ftid or a place id still wins, and they are
what a place searched through Google carries. This only changes what happens
for the ones that carry neither, which is every place added from OpenStreetMap,
by hand, or before those fields existed.
2026-08-11 16:00:03 +02:00
Maurice 8b1a238c51 fix(ui): sparkline stacking, wrapping sheet hint, translated phone entry view, Cloudflare docs (#1884)
* Put the atlas sparkline behind the text it was covering

The graphic is absolutely positioned and comes after the label, value and delta
in the DOM, so with no stacking order of its own the browser painted it on top
of them. On the dashboard's atlas row a status card is about 190px wide at
1440px, and the 80px sparkline covered more than half the delta line (#1787).
Give it z-index 0 and lift the three text rows to 1. It is decoration, so it
stops taking pointer events while we are here.

* Let a sentence-length sheet subtitle wrap

TileHeader truncates its subtitle to one line. That is right for a filename or a
date, and wrong for the reorder hint: between the 40px icon tile and the 34px
close button there is not enough width for a sentence, so it was cut off
mid-word (#1814), and every translation is longer than the English one. Opt in
with subWrap where the subtitle is prose; the file sheets stay single-line.

* Translate the phone entry view

MobileEntryView carried its own English label tables while the desktop view
resolved the same values through i18n, so a German reader got "Amazing" and
"Sunny" on a phone and the translated chips on a tablet (#1846). Which view
appears is decided by two different breakpoints, which is why it looked
arbitrary. The labels are keys now, the edit action, the photo counter and the
pros/cons headings go through t(), and the date formats against the chosen
locale instead of the browser default. No new keys: all of them already exist
in all 23 files.

The colour classes stay local. The desktop table carries raw hex values and no
dark-mode variants, so sharing one table would change how this looks.

* Name Google account linking in the Cloudflare bot section

The section claimed the block was specific to ChatGPT, so someone hitting it
with Gemini had no reason to read on (#1667). Google exchanges the code from its
own servers, which Bot Fight Mode blocks the same way, and the symptom differs
enough to be worth spelling out: the browser half succeeds and linking fails
afterwards, with a 200 on /api/oauth/authorize and no /oauth/token at all. Also
note that Bot Fight Mode ignores IP allowlists, which is the first thing people
try.
2026-08-11 11:48:01 +02:00
Maurice 8c8cc2a727 Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.

The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.

Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.

The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.

Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.

Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
2026-08-11 11:15:32 +02:00
Konstantinos Thermos 073e55cbe4 fix(auth): redirect authenticated users away from /login and /register (#1822)
Landing on /login while already signed in, by typing the URL or going back, showed the login form again (#1810). PublicRoute takes a redirectAuthed flag on those two routes and sends the visitor to the start destination instead, capturing the auth state at mount so a fresh login is left alone during the takeoff animation. Only from a bare URL: ?redirect=, ?invite=, ?oidc_code= and ?oidc_error= all have to reach useLogin intact.
2026-08-11 10:21:58 +02:00
Konstantinos Thermos b4f9e96cc9 fix(feeds): use per-endpoint zones for transport times in the ICS feed (#1874)
A transport also carries a top-level reservation_time, and that branch derives its zone from the linked place, which a flight does not have, so both ends landed as floating times in the subscribed feed (#1453). Consult a timed departure endpoint first instead and keep the IANA zone per side. When the arrival side has no clock of its own, the DTEND still comes from reservation_end_time, so a rental imported with only a geocoded pickup keeps its multi-day return.
2026-08-11 10:17:52 +02:00
bear 1edb531549 fix(auth): send Referer on the Google Maps key validation request (#1862)
The admin panel's test button reported a key with an HTTP referrer restriction as invalid, because the validation call was the one Google request that did not send a Referer. It sends the same header googleFetch already sets for every real request.
2026-08-11 10:17:28 +02:00
Maurice b56dcec118 Type the cron registrar stub so the tests typecheck again
typecheck:tests landed with the contract ratchet; the scheduler migration
merged after it, and its idempotency-cleanup test stubs register() with no
parameters. Reading calls[0][2] back off a no-parameter mock types every
recorded call as [], which is a tuple index error, so dev has been red since.
Give the stub the real register() signature and drop the cast it needed.
2026-08-11 09:58:37 +02:00
Maurice 8e4261ed81 Delete four bridges with no consumer, and gate what runs outside the container
Both were left open by the audit and are closed here.

days, packing, photos and reservations each built a service outside the
container at module load for nobody: only their own delegation tests imported
them, and three domain docblocks were still telling readers to go through them.
photos.bridge's own comment pointed at src/services/, a directory deleted two
PRs ago. The cases that exercised them kept their assertions and call the
service the bridge delegated to, so nothing that was proved stopped being
proved. Twelve bridges remain, each with a live out-of-container caller.

Coverage measured src/** but gated only src/nest/**, so the boot path, the
scheduler, the MCP handler and the migrations sat under no floor at all. That
is the same 9,602 lines where this audit found its worst defect, and a
swallowed EADDRINUSE is exactly the kind of thing an untested boot path hides.
Seven globs now pin what is measured today. They are floors to raise, not
targets that have been met: src/*.ts is at 39% statements and src/db at 38%
branches, and writing those numbers down is the point.

src/demo/** is deliberately left out. It measures 0%, and a floor of zero
asserts nothing; it needs tests before it needs a threshold.
2026-08-11 09:58:37 +02:00
Maurice 4dce8b9a7f Correct the ephemeral-token store's own justification
The docblock said the ws handshake consumes tokens from outside the container.
That was true when it was written and stopped being true one PR later, when the
transport became a gateway that injects this service.

The store still has to be module state, for a reason the comment did not give:
four bridges build EphemeralTokenService by hand, and per-instance state would
give each of them an empty Map, so a download token minted through a bridge
would never validate. An audit lens flagged the stale reasoning; the
requirement survives it.
2026-08-11 09:58:37 +02:00
Maurice 3a7c8e1d26 Make a bind failure fatal again, and undo four contract tightenings
An adversarial audit over the three merged migration PRs. Sixteen findings
survived triple refutation; these are the ones with a defect behind them.

The serious one is mine, from the gateway move. ws registers
`error: this.emit.bind(this, 'error')` on the http server it attaches to, and
the adapter now runs inside buildApp, before listen(). So from that moment the
http server HAS an error listener and Node stops throwing on one. The adapter's
handler was `() => {}`, which meant EADDRINUSE was forwarded there and dropped:
the process survived, the listen callback never ran, so the banner never
printed and the scheduler never started. A container that looks healthy and
serves nothing. Reproduced, then fixed at both ends: index.ts owns the fatal
decision on the http server, and the adapter logs instead of swallowing.

Four DTOs were tightening contracts rather than describing them, which is the
thing the pipe-before-handler ordering makes easy to do by accident:

- OauthClientCreateDto replaced 'Name is required' with a Zod field report, and
  pre-empted the handler's 403 on an instance with the MCP addon off.
- BookingImportConfirmDto demanded per-item fields the service never reads, so
  a caller assembling items itself got a 400 about `source`.
- TagCreateDto/TagUpdateDto typed name and color as strings; the legacy route
  bound whatever arrived, so a numeric name used to create a tag.
- PluginUserSettingsUpdateDto typed config as a record, where the handler
  answers 200 for anything it cannot use.

Two of my own tests were false-green, which the audit demonstrated by mutation
rather than by reading. WSGW-008 asserted on frames that never carry the user
object at all, so it passed with the password_version strip deleted; it reads
the retained identity now. GEO-009 made the same assertion before and after the
write; it distinguishes a cached negative from a miss now, through a hasCached
seam that says what getCached cannot. Both were re-run against the mutation
they exist to catch, and both fail on it.

booking-import geocodes every venue and endpoint in one request loop, up to
thirty sequential calls. Folding maps onto the shared Nominatim cursor put that
loop on the interactive lane, so an import starved every concurrent keystroke
for half a minute. It takes the background lane now, which is what the two-lane
split was built for.

Also: the README's in-container bridge list had outlived one entry and was
missing another, and the journey e2e still mocked a module this branch deleted.
2026-08-11 09:58:37 +02:00
jubnl b82651bf93 Merge remote-tracking branch 'origin/dev' into dev 2026-08-10 22:38:05 +02:00
jubnl f04023b965 delete unwanted files 2026-08-10 22:21:25 +02:00
jubnl 3a48dd7c1c docs: bring the scheduler docs in line 2026-08-10 22:20:11 +02:00
jubnl 08dac57577 refactor(server): migrate plugin jobs off node-cron
The plugin host's job scheduler now uses the cron package (the one
@nestjs/schedule wraps), so node-cron and @types/node-cron leave the
dependency tree entirely. Verified against both real libraries that the
accepted cron grammar is identical — no plugin's declared schedule tightens —
and pinned that with an unmocked compat test.
2026-08-10 22:16:22 +02:00
jubnl f43aed762d refactor(server): retire src/scheduler.ts 2026-08-10 22:04:23 +02:00
jubnl de5d196344 refactor(server): move the cache sweeps, idempotency purge and airtrail poll in-container 2026-08-10 22:02:54 +02:00
jubnl 78b63972d0 refactor(server): move the version check and demo reset into the admin domain 2026-08-10 21:56:13 +02:00
jubnl 1cc46f11b5 refactor(server): move the reminder crons onto the injected notifications 2026-08-10 21:49:27 +02:00
Maurice d981719941 Serve /ws from a Nest gateway with its own adapter
The transport was a hand-rolled setupWebSocket(server) that index.ts kicked
off with a dynamic import after listen(). It is a @WebSocketGateway now, with
DatabaseService and EphemeralTokenService injected instead of imported, and
the heartbeat owned by a lifecycle hook rather than an interval nobody stops.

The wire protocol does not move. Nest's WsAdapter dispatches on
{ event, data } and every deployed client sends { type, tripId }, so
TrekWsAdapter translates rather than asking clients to change. It also builds
the ws server itself instead of delegating: the base's port-0 branch uses
noServer and performs the upgrade from its own registry, which never consults
verifyClient, so delegating would have dropped the origin check and answered
101 to anyone. The per-socket error listener stays attached first, because an
unhandled one kills the process (#1576), and the flood guard counts every
inbound frame before parsing, because malformed frames are exactly the ones
worth limiting.

Four things had to stay where they were, and each is a silent failure if it
does not:

The socket registry stays module state. Seventeen bridges and
notifications.instance build `new RealtimeService()` outside the container;
rooms on a provider instance would leave their broadcasts going to an empty
map, with no error and no log.

The socket id stays a monotonic integer. The client echoes it as X-Socket-Id
and broadcast excludes the originator with Number(excludeSid). A uuid is NaN,
NaN === NaN is false, and every client would receive its own writes back,
which shows up as drag-and-drop that jumps rather than as an error.

buildApp creates the http server and binds the adapter before app.init(), and
callers take it from getHttpServer(). Nest binds gateways during init, so a
later adapter binds nothing; an adapter given the app binds to Nest's own
internal server, which this process never listens on. Both boot green with no
client able to connect.

RealtimeGatewayModule is imported by AppModule alone. RealtimeModule is
@Global, so a gateway there follows into every hand-assembled TestingModule,
and Nest's SocketModule then loads an adapter none of them set: it reaches for
@nestjs/platform-socket.io and calls process.exit(1). Eighteen e2e harnesses
died that way before the split.

src/websocket.ts survives as a re-export of the three fan-out primitives. 115
test files vi.mock that path to assert on broadcasts.

The two transport suites pass unchanged, and 30 new cases pin the pieces
directly: the pv and MFA gates, the numeric socket id, the plugin announcement
firing before the room check, both sides of the origin allowlist, and the
flood guard counting frames no handler wants.
2026-08-10 21:46:47 +02:00
jubnl 0ec611eb98 refactor(server)!: read the reminder gates per tick and drop the app-settings restart
The trip/todo reminder enable gates (notify_trip_reminder, notify_todo_due)
are now read inside each 9 AM tick instead of once at boot, so toggling them
takes effect at the next run without a restart. Saving notification settings
no longer re-arms anything: the auth-side startTripReminders() call and the
shouldRestartScheduler result field are gone (no controller consumed it, so
the PUT /api/auth/app-settings response shape is unchanged). The boot
'enabled/disabled' log lines are informational banners now.
2026-08-10 21:37:45 +02:00
jubnl c63d6430af refactor(server): move the auto-backup cron into the backup domain 2026-08-10 21:33:48 +02:00
jubnl 399bbda0fa feat(server): add the schedule wiring 2026-08-10 21:22:13 +02:00
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00
Maurice 7078ac7a4d Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had
changed. The 536 test files all passed; the job failed on three unhandled
rejections, all `window is not defined` from MDashWidgets, attributed to
whichever case happened to be running when they landed.

The widget fetches api.frankfurter.dev directly and sets state from both
handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so
that request was never intercepted: it left the runner, came back after the
environment was gone, and React tried to schedule an update against a torn
down tree. Load dependent by construction, which is why dev happened to pass.

Two halves, because either alone leaves the other broken. The widget now
aborts in flight work on unmount and on refetch, and treats an abort as "no
one is waiting" rather than as a failure to report. That matters outside the
runner too: this is a third party call on a mobile screen a user can leave
immediately.

The handler set gains the third party endpoints the app calls directly rather
than through /api, so no suite reaches the real host. An empty rate list is
the shape the widget already handles.

Also clears twenty more test files, each at its root cause. Two more were
passing for the wrong reason: oidc.service.test.ts built AuthService with an
AtlasService in the membership slot, undefined for userCleanup and mailer,
and the mailer stub in the tokens slot, the same shift admin.service.test.ts
had. And CAL-010 claims to serialise a flight byte for byte, but
createReservation ignores reservation_time, so the row is written with a null
time and CalendarService drops it: the recorded snapshot is truthful about
the data the case builds and silent about the flight it advertises.
2026-08-10 19:54:46 +02:00
Maurice 49c3c57c31 Give the ephemeral tokens a provider, and clear the next wave of type errors
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup
timer only because it happened to be the domain that mints tokens. Issuing
them and sweeping them are different jobs, and FilesService and TokenService
mint or consume them too.

EphemeralTokenService owns the lifecycle and hands the three operations out.
The store and the interval stay module state next door, and that is
load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on
the upgrade handshake, outside the container, before any injector is in
reach. Per-instance state would hand it an empty Map and close every
connection with 4001, which no test would catch because the ws suites drive
the transport directly. WS-027 through WS-029 pin the session gate that runs
through exactly that path.

The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken
returns null at 10,000 live tokens and TokenService turns that into a 503. A
second store would raise the real ceiling with nobody noticing.

Its own leaf module rather than a provider in AuthModule: TokensModule mints
download and ws tokens as well, AuthModule already imports TokensModule, and
TokensModule is documented as a deliberate leaf that three graphs reach for.
Same shape as MailerModule, which broke AuthModule <-> NotificationsModule.

Twelve hand-wired construction sites in tests needed the new collaborator.
The typecheck named all twelve; before this branch they would have been
undefined at runtime.

Sixteen more test files are cleared here too, each at its root cause. Four of
them were passing for the wrong reason:

files.controller.test.ts drove two handlers with shifted arguments, a string
tripId where the trip row belongs and undefined where tripId and id belong.
It stayed green because FilesService is fully stubbed and the stubs ignore
what they are given, so DELETE /:id/permanent and DELETE /trash/empty never
covered that the trip row reaches the permission check.

conflictUpdate.test.ts was missing real collaborators on PlacesService and
PackingService. None of the exercised paths reach them, so it passed by luck.

assignments.service.test.ts builds its service by hand, and the one consumer
of the collaborator it was missing swallows every throw in a bare catch. A
broken journey reconciliation would still have shown 28 green tests.

scheduler.test.ts mocks two modules with paths that resolve one directory
above the server root, so they mock nothing at all. Harmless only because
src/scheduler.ts imports neither.
2026-08-10 19:54:46 +02:00
Maurice ff286edc45 Widen the test stubs the typecheck rejected
conditions.test.ts lost noTrips when the condition kind landed; oauth.test.ts
lost the RFC 8707 resource indicator when it became required. Both are
fixtures that stopped matching a contract that grew, and both are filled in
with the value that keeps every existing case reading as it did.

budget.service.calc.test.ts typed its prepare mock from the default
implementation rather than from prepare's own signature, so all ten later
mockImplementation calls had to reproduce that exact three-member literal.
One stmt() helper names the widening instead.
2026-08-10 19:54:46 +02:00
Maurice f691da8f7e Read online users through the facade, not a lazy require
admin.service.ts reached getOnlineUserIds with require('../../websocket')
inside a try/catch — the last lazy require of the ws singleton in the tree,
and exactly what RealtimeService exists to prevent. The facade gains the
third pass-through next to broadcast and broadcastToUser, delegating at call
time like its siblings so per-file vi.mock stubs keep flowing through.

The catch stays at the admin call site rather than moving into the facade: an
admin list that shows everyone offline is a better answer than a 500, but
that judgement belongs to this caller, not to every future one.

Typechecking the tests turned up two more things behind the arity errors.

admin.service.test.ts built AuthService with an AtlasService in the
membership slot and no mailer at all, so its last four collaborators were
shifted by one. Nothing failed, because no case there reaches a path that
uses them.

Its getOidcSettings/updateOidcSettings wrappers referenced methods AdminService
no longer has. Nothing called them: OIDC settings moved to OidcService and the
two cases moved with it (ADMIN-SVC-047/048 now live in oidc.service.test.ts).
Only the wrappers stayed behind.
2026-08-10 19:54:46 +02:00
Maurice 4780a7be90 Typecheck the test tree, and fix what it found
server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.

tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.

Three of the first fixes were not type noise.

DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.

host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.

auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
2026-08-10 19:54:46 +02:00
Maurice 6cf5760383 Inject the notification senders instead of importing them lazily
Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.

The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.

notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.

The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
2026-08-10 17:08:54 +02:00
Maurice c58d797117 Write the 404 Immich streaming only returned
streamImmichAsset answered { error, status } when the effective owner had no
Immich connection stored, and left the response untouched. All four callers
ignored that return, so the client got no status line and waited out its own
timeout. Its Synology counterpart has always written through
handleServiceResult; this is the same shape.

Every other exit already went through pipeAsset, which writes on all paths
including its catch, so the method is void all the way down now — and the
PhotoProvider interface drops the error return it was carrying purely to
describe this defect.

Three cases pin it, including the one that matters for shared albums: the
connection that gets read is the OWNER's, so a viewer who happens to have
Immich configured must not paper over an owner who does not.
2026-08-10 17:08:54 +02:00
Maurice fc9742357b One photo provider interface instead of three switches
Dispatch to a photo backend was `switch (photo.provider)` in
photo-resolver.service.ts twice and, in journey-public.controller.ts, an
if/else that handed anything not named immich to Synology — where it 404'd
from inside that provider's id parser. Adding a third backend meant finding
all three.

PhotoProviderRegistry.get(id) replaces them, over adapters registered under
the PHOTO_PROVIDERS token in memories.module.ts. Adapters rather than
`implements` on the services themselves: ImmichService and SynologyService
are also the album and browse surfaces, and folding the interface into them
would drag their argument orders and return shapes along.

PhotoAssetRef is the part worth having. The two services took the same three
values in different orders — Immich as (userId, assetId, ownerId), Synology
as (userId, targetUserId, photoId) — so every dispatch site had to remember
which was which, and a transposition would have compiled and served another
user's photo. One place builds the ref, one place decrypts the Synology
share passphrase.

`local` stays an explicit branch at both dispatch sites: the bytes are on
this disk, there is no row for it in photo_providers, and its asset info
comes from the stored row rather than from a backend.

The rename memories/ -> media/ is deliberately not here. It is import churn
across 275 files, and coverage-thresholds.mjs buckets on one path segment,
so merging photos/ in would collapse two ratchet entries into one and keep
the looser of the two.
2026-08-10 17:08:54 +02:00
Maurice 2d0eca7938 Let MCP addon gates read an injected service
Nine *.mcp.ts files reached through addons.bridge for one boolean each, and
platform.routes plus the legacy registrars still do. That was never a
preference: a decorator's options object is built while the class is being
defined, so the `when:` closure had no `this` to reach an injected service
through, and the only thing in scope was a module-level singleton that
builds its own AddonsService outside the container. Nine domains hid an edge
to the addons domain from the module graph to answer "is this addon on".

@trek/nest-mcp hands the gate its declaring instance now, resolved at
attach rather than captured at module load, and addons/addon-gate.ts turns
that into addonGate(ADDON_IDS.X) over the injected service. The option is
declared with method syntax so a predicate written against its own
controller class stays assignable.

Two more edges went the same way, both checked for cycles first:
reservations.mcp injects AssignmentsService (AssignmentsModule never
reaches back), atlas.mcp and journey.mcp inject AuthService.

The tests that forced a toggle by mocking the bridge module now write the
addon row the admin panel writes, so they exercise the real read.

Four in-container uses stay, each documented in the nest README: places.mcp
(a real Days -> Places -> Assignments cycle), budget/packing/costs against
trips.bridge, user-cleanup against budget.bridge, and the two multer
interceptor options, whose fix is MulterModule.registerAsync.
2026-08-10 17:08:54 +02:00
Maurice 328b5fcb2b One Nominatim client for atlas and maps
Atlas throttled to the 1.1s OSM asks for and cached its answers in a 50k
map; maps did neither, so five interactive routes fired straight at the
service while the two atlas backfill loops politely waited their turn.
From Nominatim's side that is one instance ignoring the rate limit.

Both go through geo/nominatim.client.ts now. Three things the naive merge
would have got wrong:

- The shared client sends UA, the instance-identifying User-Agent, not the
  generic string atlas used. A shared generic UA is throttled harder.
- One cursor across five interactive routes and two background loops would
  have put a keystroke behind however many places the backfill enqueued, so
  there are two lanes over the one cursor and background yields.
- The cache key carries the query shape. Atlas asks at zoom 3 and 8, maps at
  18; a coordinate-only key answers a street lookup with a country.

The throttle cursor and the cache stay module state, the way the permissions
and FX caches do, because the bridges build their collaborators outside the
container and a second copy of the cursor is the bug being removed here.
GeocodingService owns only the cleanup timer's lifecycle, which atlas used to
start as an import-time side effect.

Also folds the three haversine implementations into common/geo.ts, keeping
the clamped form: asin of a value a hair over 1 is NaN, and a NaN distance
fails every comparison it feeds instead of throwing.

tests/setup.ts zeroes the interval. Nineteen maps cases drive these paths
back to back with real timers and were sleeping nineteen seconds for it.
2026-08-10 17:08:54 +02:00
Maurice 55da861de6 Put the reservation imports behind one door
Two controllers declared the identical @Controller string from two modules and
each rebuilt the trip access check its own way: booking-import ran it as a
guard chain, integrations ran it by hand inside the handler, and
BookingImportService carried a third copy that nothing called at all.

They never actually collided - the four file-upload sub-paths and the one
AirTrail sub-path are disjoint - so this is not a bug fix. It is one access
path instead of three implementations of the same one, and the dead copy is
gone.

The addon requirement sits on the AirTrail handler rather than the class.
Class-level would have 404d the four upload routes whenever AirTrail happened
to be off. AddonGuard still runs first in the chain, because a disabled addon
has to answer 404 to an anonymous caller before anything answers 401, and
ADDON-GUARD-006 pins both halves of that.

One deliberate behaviour change: the AirTrail route now gets its trip 404 from
the guard, ahead of the body 400, because a guard runs before the pipe. That
is the ordering every other trip-scoped controller already has and nothing
pinned the other way for this route.

The class rename would have stopped the server booting: the body-contract gate
keys on ControllerClass.methodName and throws on stale entries. Three entries
renamed, and the list re-sorted - GATE-006 caught that the rename had broken
the ordering it asserts.

booking-import lost the controller from its coverage denominator, so the
branch floor was about to fall. It goes up instead: the off-request parse had
no case for its catch arm, so a throw would have left the job on 'running' and
the widget spinning, and the binary probe behind /api/health/features had no
test at all - that one boolean decides whether the client offers booking
import.
2026-08-10 17:08:54 +02:00
Maurice 4965281644 Delete src/services and wall off the way back
The directory is empty after the airtrail fold, so it goes. The ESLint rule is
the part that matters: the directory disappearing is not what keeps it gone,
because the way it grew was one file at a time, each one reasonable on its own.
Any import of a services/ path is an error now, with the message pointing at
the recipe in the module guide.

Proved rather than assumed: a probe file importing the old airtrail path fails
the rule, and the whole package still lints with zero errors.

Not changed: the 91 provenance comments in shared/, client/ and server/ that
name a services/*.ts file. They are statements about where a schema or a
service came from, and they stay true as history. Rewriting them all would put
91 files of noise over the change worth reviewing, and the paths they cite are
already gone from the tree, which is the honest signal that they are history.
2026-08-10 17:08:54 +02:00
Maurice 19f999329e Fold the last legacy service into its domain
src/services/ held one thing: airtrail/, 1305 lines of plain functions over the
better-sqlite3 singleton. They are four providers and two pure helper modules
now, and the directory is empty.

Five hidden edges go with them. The db singleton in three files, the raw
broadcast from src/websocket in two, and the reservations, addons and audit
bridges - all module-level imports that the module graph could not see.

The scheduler stops reaching for it at call time. scheduler.ts ran
require('./services/airtrail/airtrailSync') inside the cron tick, so a wiring
mistake surfaced as "AirTrail sync tick failed" every five minutes rather than
at boot. It takes the dependency through SchedulerDeps now, the same structural
contract backups and placePhotos already used.

One edge stays a seam, deliberately. AirtrailSyncService needs
ReservationsService, because the inbound pull applies remote changes through
the real reservation update path - endpoint restamping and all - and
reimplementing that against raw SQL would be worse than the coupling. The
write-back trigger runs the other way, from reservations.controller, and
injecting there would close
ReservationsModule -> AirtrailModule -> ReservationsModule. airtrail.bridge.ts
holds that one function with the reason in its docblock, rather than a
forwardRef hiding it.

The write-gate suite was module mocks over free functions and is stubs through
a constructor now; it gained four cases on the way (auth failure, remote
delete, disconnected owner). The domain ratchet goes from 19/0/10/19 - set when
integrations was two thin controllers - to 69/52/72/71, which is what the
folded code measures. It is a 1:1 move, so it arrives with the coverage its
tests always gave it.
2026-08-10 17:08:54 +02:00
Maurice f601bd8d47 Gate the enrichment domain at what it actually measures
place-enrichment landed on dev without an entry in the per-domain coverage
table, so it sat on the 80/80/80/80 catch-all while measuring 99.5 statements
and 99.0 branches. That is the exact hole the per-domain ratchet was
introduced to close: one well-covered domain quietly carrying nineteen points
of headroom nobody would notice losing.

The rest of that work needs nothing from this branch. It injects
DatabaseService rather than reaching for the db singleton, reads no
process.env, carries a Zod contract in @trek/shared with a colocated DTO, adds
no allow-list entry, and its controller declares @UseGuards(JwtAuthGuard) -
which is what keeps it authenticated under the default-deny guard this branch
introduces, since a declared chain makes the global guard stand down rather
than answer first.
2026-08-10 13:38:26 +02:00
Maurice 11afd3648e Put the coverage back where the code went
Every split on this branch dropped the giving domain's ratchet without a line
being tested any worse: coverage follows the source file, so when
well-covered code leaves, the weaker branches that stay behind become the
whole denominator. days fell from 82 to 77.5 percent branches that way, feeds
to 76.7, trip-read-model to 62.5.

The rule in vitest.config.ts is that the only way past a number is to raise
it, so these are tests, not edits to the thresholds. days is back at 92.1,
calendar at 96.2, trip-read-model at 100.

feeds and trip-members and trip-read-model had no unit test at all - feeds'
whole threshold rested on one e2e, which is exactly the file whose contract
this branch rewrote. They have one now, and the read-model test pins the
thing in that file that would leak data if it regressed: getTripSummary and
bundle both thread the viewer id into packing.listItems so another member's
private items stay hidden, and nothing asserted that before.

The five thresholds this branch introduced move up to what they now measure,
with a few points of slack. The pre-existing entries are left alone on
purpose: they were generated from a Linux run, CI measures there, and a
handful of branches are platform-specific - raising them from a Windows
measurement is how you get a failure that reproduces nowhere local.
2026-08-10 13:38:26 +02:00
Maurice 22ee0bafdc Bring the module guide in line with the guards
The guard section still said BE-Phase 2 was in progress and listed
TripAccessGuard, default-deny and the MFA policy as open. All three landed on
this branch, and two of them changed how a contributor is supposed to write a
controller, so the file that tells them how needs to say it: what
GlobalAuthGuard does with a declared @UseGuards chain, why places splits along
"does this handler take a DTO-typed body" rather than along the domain, and
why TripOwnerGuard must not go near PermissionsService.

It also still pointed at middleware/mfaPolicy.ts in two places, which this
branch deleted.
2026-08-10 13:38:26 +02:00
Maurice e5c99903ea Pin the request body ceiling instead of inheriting it
The Express shell set a 100kb limit and stopped applying it the moment the
Nest instance took over parsing: bootstrap passed bodyParser:false and
nothing replaced it. The number happened to stay the same, because it is also
the framework default - but it lived nowhere anybody would look for it, and
one NestFactory default away from silently becoming something else.

It goes through Nest's own parser rather than a second express.json(),
because that parser carries the verify hook that keeps req.rawBody, and the
plugin webhook routes need the exact payload bytes to check a provider's
HMAC. Adding a parser in front of it would have taken rawBody away without
failing anything.

MISC-009 and MISC-010 pin it from both sides.

The bodyParser flag on applyGlobalMiddleware goes with it: one caller, which
passed false.

Not moved: cookieParser stays on the raw Express layer rather than becoming a
configure(consumer) middleware. The platform routes - the OAuth consent flow
among them - are registered on that instance before Nest routing exists, so a
Nest-slot cookie parser would simply not run for them. helmet, CORS,
compression, the forced-HTTPS 301 and the res.on('finish') logging stay for
the same reason: they have to see requests Nest never routes.
2026-08-10 13:38:26 +02:00
Maurice be8081c3b4 Enforce the MFA policy as a guard, and stop 403ing public routes
The last live non-shell middleware verified the JWT and read the users row a
second time on every /api request, then threw the loaded user away - two
jwt.verify calls and two SELECTs per authenticated request, because it ran
before Nest and could not see what the auth guard had already resolved. The
guard reads req.user.

It also corrects an asymmetry that had grown quietly. The middleware decided
on two literal path lists, and every public /api endpoint added after those
lists were written - /api/config, /api/help/*, the public journey and share
routes, /api/health/features - answered a logged-in user without MFA with a
403 while answering the same request from a stranger perfectly well. Nothing
intended that; the lists simply stopped being maintained. Keying on @Public()
means the exemption cannot drift from the route again. This is a deliberate
behaviour change and the only one here.

@MfaExempt carries the reason for the other kind of exemption: the routes
that let somebody complete setup. Without them the policy locks out every new
account, which is why the middleware had its second list.

One ordering trap on the way: global guards run before route guards, so the
auth guard standing down on a controller with its own @UseGuards chain left
req.user unset when the MFA guard ran behind it, and it waved everything
through. AUTH-020 caught it. It resolves the user now without refusing - the
declared chain still owns the 401.
2026-08-10 13:38:26 +02:00
Maurice ffb73fac20 Turn protection from opt-in into default-deny
Nothing in src/nest/** was an APP_GUARD. Every protected route said so
itself, 62 times, which means a forgotten @UseGuards was a silent bypass
rather than an error. GlobalAuthGuard inverts that: a route is authenticated
unless it carries @Public() or @OptionalAuth(), or declares its own guard
chain.

Reading the declared chain is what makes this behaviour-neutral. Nest runs
global guards before route guards, so without that check the global guard
would 401 an anonymous request to an addon-gated controller before its
AddonGuard could answer 404 - inverting an order four e2e suites pin, and
leaking which addons are installed. A controller that declared a chain keeps
deciding for itself.

It is a new class rather than JwtAuthGuard gaining a Reflector: that guard is
constructed positionally in its own test with a context stub that has no
getHandler, and the two want opposite behaviour anyway.

@Public() takes a required reason, and the reasons are the diff worth
reading: the invite token IS the credential, help assets are loaded by <img>
which cannot send one, logout has to work with an expired session or nobody
can sign out.

The real deliverable is the inventory. validateRouteGuards runs in buildApp
next to the body-contract gate and refuses to start when a route answers
anonymously without being on a reviewed list of 25 - or when a listed one no
longer does. An allow list nobody prunes stops being a list.

Default-deny found two controllers on its own: SharedController and
OidcController both sit in a file next to a guarded controller, so a
file-level search for unguarded ones missed them. 37 tests went red and named
them. That is the mechanism working before the change shipped, not after.
2026-08-10 13:38:26 +02:00
Maurice ac49191ec8 Describe the journey bodies instead of tightening them
Sixteen of the thirty-four entries in the body-contract allow list were the
JourneyController, and a strict schema already existed for six of them. That
is the trap: eight of these handlers validate their own body and answer with
their own text - 'Title is required', 'trip_id required', 'provider and
asset_id required', 'entry_date is required', 'user_id required' - and those
bodies are pinned by e2e and integration cases. Adopting the strict schemas
would have the pipe answer first, with a different body. That is a
client-visible change wearing the costume of a refactor.

So the schemas follow the memories doctrine instead: loose objects, and every
field the handler type-checks itself is unknown here. They describe the shape
that already works, which is what the boot gate needs, and every hand-rolled
400 keeps answering. The spec inverts accordingly - it now asserts that each
schema ACCEPTS what the handler still rejects, which is the property that
makes the claim checkable.

Two of them are optional at the top level, and that is not decoration: a
multipart upload with files and no text fields arrives with no body at all,
and JOURNEY-INT-045 pins 'No files uploaded' for it. Without the optional the
pipe answered 'body: Invalid input: expected object' instead. The suite
caught it.

Backup joins them - its schema was already a passthrough, because the service
normalises the body itself.

Allow list: 34 entries down to 17. Tags and the two OAuth entries stay for
now; tags reads @Body('name') per property, which is a signature change
rather than a contract one.
2026-08-10 13:38:26 +02:00
Maurice 4591645316 Make ownership a guard instead of four hand-written checks
src/nest/** had no owner guard at all. Handing a trip over and creating or
deleting guests each asserted access.user_id !== user.id inline, and the
string they answer with differs per route, so the message travels in the
metadata rather than living in the guard.

TripOwnerGuard deliberately does not inject PermissionsService.
checkPermission returns true for every admin and the trip actions are
admin-lowerable, so routing ownership through it would quietly let any admin
transfer somebody else's trip. OWNER-004 pins that: an admin who is not the
owner gets 403.

places gets protection on the five routes where a guard is provably safe:
list, get, unrate, image and delete. Not on create, update, the imports or
the uploads. A guard runs before the pipe, so guarding a route with a
DTO-typed body would answer the trip 404 where the suite pins a 400 - that
includes PUT :id/rating, which is why the rating pair is split. The three
multipart routes are excluded for the other reason: answering while the
client is still streaming destroys the socket, and the caller sees
ECONNRESET instead of a status.

DELETE :id carries @RequirePermission('place_edit'). The first attempt at
this lost that check and answered 200 where 403 belonged, so the e2e asserts
the argument list rather than the status: a check that returns the right code
for the wrong reason fails now.
2026-08-10 13:38:26 +02:00
Maurice d95aa49c7f Split the trip aggregate root into its parts
TripsService injected fourteen other services. Almost all of them were there
for two read aggregates - the MCP trip summary and the offline bundle - and
for the member roster, not for anything the write path does. Reading is what
fans out; writing is not.

Membership goes to trip-members/: listMembers, addMember, removeMember,
transferOwnership and the four guest methods, plus the six routes, which keep
their paths. It is a NEW module, not the trip-membership/ leaf next door:
deleting a guest erases their plugin data and re-splits their expenses, so it
needs auth and budget, and AuthModule already imports that leaf. Giving it
those dependencies there would have closed a hard cycle. This one is a sink -
nothing imports it back except trips.

The read aggregates go to trip-read-model/, which is where the nine-service
fan-out now lives.

What is left is the write core plus day generation, on eight parameters
instead of fourteen: database, reservations, days, permissions, budget,
vacay, realtime, unsplash. The last four are all reachable from updateTrip's
date-shift transaction, which is why the count does not fall to three.

Trips still reaches budget through ReservationsModule, so trips.bridge.ts
survives this. Saying otherwise would have been the easy claim to make and it
would not have been true.
2026-08-10 13:38:26 +02:00
Maurice 3d522be0e5 Give accommodations one home instead of two halves
The routes lived in reservations/ while the SQL lived on DaysService, so the
controller delegated every call across a domain boundary to reach its own
data, and neither module owned the thing outright. The seven statements move
into AccommodationsService unchanged and the domain gets its own directory,
module, DTO, RPC and MCP surface.

What stays in days/ is the reorder side: assertNoInvertedAccommodation and
resyncAccommodationDays are about re-dating day rows, and the stay is the
thing being carried, not the thing doing the carrying.

Two edges fall out of this. AccommodationsService drops its DaysService
dependency, and DaysMcp drops DatabaseService and PlacesService, because
create_place_accommodation was their only user - which lets DaysModule stop
importing PlacesModule and closes one leg of the latent
Days -> Places -> Assignments -> Days cycle.

DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with
their case ids intact. The reservations e2e container assembles both modules
now: it builds a single domain module on purpose, which is exactly what
catches a move like this, and it caught it.
2026-08-10 13:38:26 +02:00
Maurice ad312cdd43 Give day notes a domain of their own
days/ carried two fachlichkeiten with a full file set each: days.controller,
days.service, days.mcp, days.rpc, days.dto next to the five day-notes ones.
The note files move out unchanged; because the new directory sits at the same
depth, not one import path inside them changed.

The two modules deliberately do not reference each other in either direction:
dayExists is raw SQL on the days table in the note service, and DaysService
reads day_notes the same way, so a module edge would buy nothing and cost a
cycle.

DayNotesModule has to be registered in PluginsRuntimeModule as well as
AppModule - DayNotesRpc owns four of the 113 wire methods and the registry
boots with requireTotalCoverage, so leaving it out would stop every test app
that assembles the plugin runtime.

The days e2e container assembles both modules now: the note routes still nest
under the days prefix, and a container that builds only DaysModule would 404
on them while production served them fine.
2026-08-10 13:38:26 +02:00
Maurice 0d8870cff7 Stop two test suites from depending on the network and the scheduler
The second flake recorded as 'costs' is not a timing flake. It is a live
HTTPS call: budget.service.ts settlement() reaches getRates()
unconditionally, unlike the two guarded call sites above it, and
ExchangeRatesService fetches api.frankfurter.dev with a 10 s abort.
tests/integration/budget.test.ts stubbed fetch nowhere at all, so the suite
talked to the internet - fast when the API answers, ten seconds per call
when it does not. The stub fails closed, which changes no assertion: every
settlement case here is single-currency, so rates never enter the
arithmetic. tools-budget-advanced had the stub inside a single case; hoisted
so a second settlement case cannot quietly reintroduce the call.

The five ReDoS budgets in maps.service.test.ts measured wall-clock time,
which on a loaded machine reports how long the test waited for a core.
MAPS-024 has gone red at 523 ms against an unchanged file for exactly that
reason. They now measure CPU time, which is what catastrophic backtracking
actually burns. This does not soften them: CPU time is always <= wall-clock
time, so every budget got stricter, and pool:'forks' keeps the measurement
to this file.
2026-08-10 13:38:26 +02:00
Maurice 7f5c537ad6 Hand the feeds calendar parts instead of a document to re-parse
The all-trips feed merges many trips into one calendar. It did that by
generating each trip's finished ICS and then scanning the text back apart:
one pass for the VEVENTs, one for the VTIMEZONEs, plus a string replace to
splice the refresh hints into the header. The scans had to be structural
rather than regexes, because a user-supplied SUMMARY can legitimately
contain the literal 'END:VEVENT' and a lazy match would truncate the event
there.

CalendarService now returns the parts - calendar name, filename, VTIMEZONE
blocks keyed by TZID, and the VEVENT blocks - and exportICS serialises them.
Both scanners are gone.

CAL-010 pins the whole serialised document as a snapshot, with DTSTAMP and
the UID counter normalised so it pins the calendar rather than the minute it
ran in. It is what proves this changed no bytes: it was taken before the
model went in and still matches.

Two tests still pointed at the health controller deleted in d37af204:
health.di.test.ts loses its only subject, and the AppModule wiring test now
resolves FeaturesController instead - it is the one test that builds the
whole container, so it should resolve something that still exists.
2026-08-10 13:38:26 +02:00
Maurice 5a9ded6676 Move the calendar export off the trip aggregate
exportICS and its four module helpers were 330 lines of RFC 5545 inside
TripsService, and FeedsService had to import the whole trip aggregate to get
an ICS string out of it. They move to a calendar domain unchanged - same
statements, same escaping, same folding, same VTIMEZONE fallback - so the
emitted bytes are identical on both the download and the feed.

The 21 unit cases move with the code and keep every assertion, which is what
makes that claim checkable rather than asserted. FeedsModule now imports
CalendarModule instead of TripsModule, and its e2e container drops from the
trip aggregate to two small modules.

NotFoundError/ValidationError move to common/ so a second domain can raise
them without importing the first one for its error types; trips re-exports
them, so the nine files that import them from there are untouched.

Fixes a harness that tsc cannot see on the way past: mcp-test-controllers
built TripsService with 12 arguments for 14 parameters, leaving unsplash and
userCleanup undefined.
2026-08-10 13:38:26 +02:00
Maurice cc9587676a Give the feature-flag endpoint a health module of its own
GET /api/health/features lived in booking-import, which meant the client's
capability probe was reachable only because that domain happened to be
registered. Move it to health/ and give health an actual module instead of
two loose entries in app.module.

The extractor probe follows into a leaf module rather than health importing
BookingImportModule: features() needs isAvailable(), not llm-parse,
reservations, budget, maps and places.

The /api/_nest/{health,me,echo} smoke endpoints go. They proved boot, DI,
the cookie guard and the Zod pipe back when nothing else did; every one of
those now has real domain coverage, and nothing outside their own test ever
called them. The container probe is GET /api/health on the raw express layer
and is untouched.
2026-08-10 13:38:26 +02:00
Maurice 36cdb36875 Keep the pictures as a grid, wider tiles instead of a lead image 2026-08-10 12:24:15 +02:00
Maurice 46318db5ac Give the detail column a lead image and pin its one action 2026-08-10 12:24:15 +02:00
Maurice ad6640cdb2 Fill the gaps Google leaves from the free sources 2026-08-10 12:24:15 +02:00
Maurice 0cd67675c5 Describe the chain when nothing describes the branch 2026-08-10 12:24:15 +02:00
Maurice 92e4eaa476 Move the enrichment switch to the domain that owns the places flags 2026-08-10 12:24:15 +02:00
Maurice e3b220603c Point at the Google key only where its absence is the reason 2026-08-10 12:24:15 +02:00
Maurice e321bfcf1b Match a place name across its inflected forms, and forget an empty answer quickly 2026-08-10 12:24:15 +02:00
Maurice e1a52ba43c Stop a picked place leaving its website on the next one 2026-08-10 12:24:15 +02:00
Maurice 880e258438 Show a week of opening hours as a week 2026-08-10 12:24:15 +02:00
Maurice 0c8788ce44 Give a Google place its free half back 2026-08-10 12:24:15 +02:00
Maurice 8f9af76204 Ask the sources that know the place before the ones that know the area 2026-08-10 12:24:15 +02:00
Maurice 14d6f2ecc9 Keep the hover card's corners round without its tail 2026-08-10 12:24:15 +02:00
Maurice 2a94a5eb8e Put the add-place dialog on the theme tokens 2026-08-10 12:24:15 +02:00
Maurice e03b6a3080 Match the keyboard button to the date field beside it 2026-08-10 12:24:15 +02:00
Maurice 0d58080fa6 Lay the trip form out in two columns and give it the dashboard's shapes 2026-08-10 12:24:15 +02:00
Maurice a2a9c3094c Drop the tail from the map hover card 2026-08-10 12:24:15 +02:00
Maurice f5a483da64 Get the pictures right and stop reusing stale answers
Three things you could see on dev1.

Pictures were "roughly right" rather than right, because a coordinate search
only knows what was photographed within 300 m. Wikidata's P18 is one picture
somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the
restaurant, not a statue down the road - so it now leads, then the Commons
category, and geosearch is the last resort it should always have been. Small
venues carry a wikidata tag far more often than a Commons category, so this is
also what reaches the places the other sources miss.

Descriptions stayed on Wikipedia after the switch to Wikivoyage because the
week-long result cache predates it. It carries a format version now; a release
that changes the shape or the sources invalidates its own entries instead of
serving last week's for another six days.

Ratings and opening hours were being fetched from Google and then dropped on the
floor - the fact list only looked at OpenStreetMap tags. Both are shown now,
whichever provider supplied them.

Also: the column follows a place opened from the map, not only one picked from
search. A POI tap arrives as prefillCoords and never touched the selection, so
the column kept showing the previously added place.

Pictures are a tight three-column grid and only the picture in play is credited
in full, on one line. Crediting all of them at two lines each drowned the
column, and the licence attaches to the one that gets used - whose credit also
stays visible in the inspector after saving.

The request carries the place record the dialog already fetched, so the server
skips a details lookup it was making in parallel with the client's own. That
lookup was measured at 12.8 s for a large OSM relation.
2026-08-10 12:24:15 +02:00
Maurice d98d597581 Make the detail column useful for places no encyclopaedia describes
A restaurant got nothing: no wiki article, and a coordinate search around it
returns whatever was photographed nearby rather than the place itself. Three
changes, all on free sources:

OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours,
a link to its menu, outdoor seating, step-free access and diet tags. Those
arrive with the details lookup the dialog already makes, so showing them as
chips costs nothing and is the single biggest gain for exactly the places that
were empty before. Only positive facts are listed - "no outdoor seating" is
noise.

Commons categories. Where a place has a wikimedia_commons tag, its category is
pictures OF it; geosearch falls back in when the category is empty.

Wikivoyage before Wikipedia. Same API, same licence, but it describes a place
for someone about to visit rather than opening with area in square kilometres.
Two sentences instead of three, because this sits beside a form.

The column also follows the section-heading idiom Vacay and the dashboard share
(uppercase overline, wide tracking), its header matches the Saved places panel
beside it, and a lead picture with a row underneath replaces the two-column
grid that left a hole on every odd count. Credits wrap over two lines instead
of truncating - a cut-off name credits nobody.

Saved places stops after ten with a Show more button; search and filters still
run over everything, not just the visible page.

Form spacing is one rhythm (12px between groups, 8px inside, 4px label to
field) and Notes matches Description in height. The OSM notice fits on one line
and no longer claims there are no photos, which stopped being true when the
column started fetching them from Wikimedia. The clipboard hint is gone; paste
still works.
2026-08-10 12:24:15 +02:00
Maurice fc9ac64496 Rebuild the enrichment cache when its pictures were swept
The nightly photo sweep deletes every candidate nobody picked, which is most of
them. The cached result still pointed at those files, and the read filtered them
out - so the morning after, a place with perfectly available pictures showed an
empty column, and kept showing one for the rest of the week the entry lives.

A missing candidate now invalidates the whole entry instead of shrinking it.
2026-08-10 12:24:15 +02:00
Maurice 1f1ed630fc Fetch the enrichment pictures concurrently
The column showed "details could not be loaded" for every place. The endpoint
was answering correctly, just after ten seconds, and the shared axios instance
gives up at eight.

The downloads sat in a loop: five Commons hits meant five round trips and five
decodes in sequence, even though the slot limiter already allows five at once.
Both provider listings now run together and the bytes are fetched concurrently,
which brings a warm request back to about a second.

Candidate keys move with it. They now number the provider's list rather than the
surviving strip, so a picture that fails to download leaves a gap instead of
renumbering the ones behind it - a key has to point at the same file on the next
request or the cached result goes stale against the photo cache.

The client asks for enrichment with a longer timeout, and the Wikimedia calls
carry one of their own: a cold TLS handshake from a fresh container was measured
at eight seconds on its own, and no pictures is a better answer than a request
that never comes back.
2026-08-10 12:24:15 +02:00
Maurice b36103d2a9 Document where place pictures and descriptions come from
NOTICE knew about geoBoundaries, OSM and OurAirports but not about Wikimedia,
Wikipedia or Google Places, all of which TREK has been calling for a while.

Also fixes two dead paths in the place contract - it still pointed at
server/src/routes/places.ts and services/placeService.ts, both long gone.
2026-08-10 12:24:15 +02:00
Maurice 5d99886b04 Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail
only showed up there after a reload. place:updated now asks the planner to
reload them, but only when the image actually changed - renaming a place must
not trigger a refetch.

The inspector shows the stored credit under the avatar. Crediting a CC BY-SA
picture only in the moment it is chosen is not enough; the obligation travels
with the picture.

Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension
check did not, so one silently fell back to the category icon.
2026-08-10 12:24:15 +02:00
Maurice 4843ebc937 Show place details beside the search field
Picking a search result fills a column on the left of the add-place dialog -
pictures and a description, no extra click. Someone who already knows the place
ignores it and saves exactly as before.

The column stays mounted with an empty state instead of appearing on the first
pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in
memory and in sessionStorage and aborted when the selection changes, so clicking
back and forth between two results does not pay twice.

Every thumbnail carries its author, its licence and a link to the file page.
That is not decoration - most of these are CC BY-SA, and reusing one without
naming the author does not satisfy the licence. The description is shown but
never written into the place on its own; a button copies it across, and it is
disabled while the user already has a description of their own.

Desktop only. The mobile sheet is untouched.
2026-08-10 12:24:15 +02:00
Maurice f2368f86cf Add an admin switch for place enrichment
Reads fail-open, unlike the three older places_* switches. Those read
=== 'true', which is why migration 185 had to backfill a row on every existing
install; there is nothing here that warrants repeating that.

The three hand-rolled toggles next to it move to ToggleSwitch while I am in
there - they were bare buttons with an inline transform and no aria-pressed or
label, so a screen reader could not tell their state.
2026-08-10 12:24:15 +02:00
Maurice e9f9cfb5d5 Add the place enrichment endpoint
Fans the new provider primitives out into one answer for a place that is being
looked at but not saved yet: a strip of pictures and a description, each with
its source.

Free sources are the normal case - most instances have no Google key at all, so
Commons and the OSM tags have to carry this on their own and Google is what an
admin adds on top. Pictures are copied into the existing photo cache under
"<placeId>~pN" keys rather than hotlinked: Google's photo URLs expire (migration
107 had to repair exactly that once), and a provider URL in the strip would send
every viewer's address to Google or Wikimedia. Whatever nobody picks is
unreferenced and the nightly sweep clears it out.

The result is cached for a week in place_details_cache under a third expanded
value, so no migration. Rate limited per user, and a provider being down answers
empty rather than failing the dialog.
2026-08-10 12:24:15 +02:00
Maurice 23e76cc3ec Let the maps providers hand back candidates, not just one picture
Commons geosearch already returned five files and we threw four away, so a
whole strip costs nothing extra. It now comes back with the licence metadata
too: without LicenseShortName and the file description page there is no way to
credit a CC BY-SA image, and a picker full of them needs that.

Wikipedia descriptions resolve through the OSM wikipedia tag rather than
guessing the article from the place name, which lands on the wrong article for
anything ambiguous. Google photo references and photo bytes are separate calls
now so a caller can list without downloading, and the editorial summary has its
own lean field mask - asking for it through the expanded details would drag
reviews along and move the call into the pricier tier.
2026-08-10 12:24:15 +02:00
Maurice 4cd50e6ed3 Add a contract for place enrichment 2026-08-10 12:24:15 +02:00
Maurice c674df72a2 Keep the atlas controller test free of the MCP SDK chain
Asserting the module registration meant importing AtlasModule, which pulls
AtlasMcp and the whole MCP SDK behind it into a file that otherwise
constructs a controller with a stub. It does not resolve when the file runs
on its own.

PROFILE-014 already covers the registration, and covers it better: it calls
GET /api/auth/travel-stats through the full app.
2026-08-09 22:43:05 +02:00
Maurice accd806786 Give the travel-stats cases a file of their own
atlas.service.test.ts carries the admin1 GeoJSON bundle warm-up from #48, and
its ATLAS-UNIT-021..024 cases sit close to the 15s limit whenever the machine
is busy. Eighteen more cases in the same file is not what they need.

Same DB-per-file pattern, no shared state, case IDs unchanged.
2026-08-09 22:43:05 +02:00
Maurice 9895c7112a Give the admin OAuth-session panel back to the oauth domain
The last of AdminService's borrowed SQL. oauth_tokens belongs to oauth/,
which already had a user-facing view of the same table; AdminService carried
a second one only because the panel route is under /api/admin.

Both survive, under names that say which is which. They are not duplicates:
the user-facing listOAuthSessions is scoped to one user, the admin one spans
all of them and joins the owner username, and the admin revoke kills the
client session for whoever owned the token.

OauthModule gains its first export. Its header said nothing inside the
container injects the domain, which stops being true here.
2026-08-09 22:43:05 +02:00
Maurice 93bbc65520 Return the registration invites to the domain that consumes them
invite_tokens gates signup. Its consumer is the registration path in
AuthService, not anything in admin/, which held the four management methods
only because the routes sit under /api/admin.

Worth spelling out because the names invite it: invite_tokens and
trip_invite_tokens are different tables for different jobs, and this one has
nothing to do with adding a member to a trip. An earlier reading of the plan
had these going to trip-invite/, which would have been the wrong home.

The routes keep their paths and their admin guard; AdminController injects
the service instead of carrying another domain's SQL.

Also recorded, in the file, why the app-settings pair did NOT move to
settings/ with the other app_settings readers: updateAppSettings runs the
lockout guard that stops an admin disabling every login method, and that
guard is auth's own resolveAuthToggles. Moving it would put an AuthModule
import inside a leaf module half the container pulls in.
2026-08-09 22:43:05 +02:00
Maurice 45611e2514 Separate the profile a user administers from the identity they log in with
The second cut into AuthService. Display settings, avatar, the third-party
API keys a user pastes in and the member directory are not identity: no
password is checked, no session is issued, no MFA secret is touched.

UserProfileService takes those eight methods verbatim, with DatabaseService
as its only injected dependency. AuthService keeps register, login, JWT,
MFA, password reset and account deletion, and is now roughly 900 lines
lighter than it started this branch.

Not exported from AuthModule: only its own controller needs it, and an
export nobody imports is noise.

The controller test harness builds AuthController positionally, so the two
new parameters landed in optional slots and the stubs for the moved methods
moved with them.
2026-08-09 22:43:05 +02:00
Maurice 03fbced20b Move tokens out of AuthService into a domain of their own
AuthService carried identity, profile, settings and tokens at once. Tokens
are the cleanest of those to cut loose: they touch one table plus the
ephemeral-token store, and nothing in them needs to know how a password is
hashed or how a session is established.

TokenService takes the six user-facing methods plus verifyMcpToken, and the
two admin-side ones that only lived on AdminService because the admin route
did. The pairs are genuinely different queries rather than duplicates, so
both survive under names that say which is which: the user-facing ones scope
every statement by user_id and treat the session sweep as best-effort, the
admin ones do neither.

Left behind on purpose: verifyJwtToken, which is login identity and belongs
next to the cookie logic, and isDemoUser, which is a demo gate that happens
to read the users table.

TokensModule is a leaf with nothing but DatabaseService, because auth, admin
and the MCP transport all import it and anything added here lands in three
graphs at once.

Both bridges and both controller test harnesses construct these classes
positionally and had to be pulled along; the new argument goes in an
optional slot so untouched call sites stay untouched.
2026-08-09 22:43:05 +02:00
Maurice e8d4278899 Give travel stats to the domain whose data it reads
getTravelStats sat on AuthService for historical reasons only. It reads
places, trips, visited_countries, place_regions and reservation_endpoints,
and subtracts the countries hidden in Atlas, so every one of its sources is
an Atlas concern.

Keeping it there had a real cost: AuthModule had to import AtlasModule, and
that edge is the reason atlas.mcp.ts reaches AuthService through auth.bridge
instead of injecting it. The direction is now free to reverse.

GET /api/auth/travel-stats keeps its path. The client calls it there, so the
route moves to a small controller in atlas/ that holds the /api/auth prefix
rather than changing the URL. PROFILE-014 runs against the full app and
proves the endpoint is untouched.

KNOWN_COUNTRIES moves along, since getTravelStats was its only reader. Both
bridges construct AuthService by hand and needed the argument dropped.
2026-08-09 22:43:05 +02:00
Maurice 8a09f55571 Give three more admin surfaces back to the domains that own them
default-user-settings, notification-preferences and oidc leave AdminController.
The first two were pure pass-throughs on AdminService that did nothing but
forward and cast; the third had its SQL there for a domain that already had a
module of its own. AdminService loses SettingsService and
NotificationPreferencesService entirely.

Two things came out of the move rather than going into it.

The OIDC lockout guard now has tests. It refuses to clear the SSO config while
password login is disabled — otherwise the instance has no SSO to log in through
and no password form either — and nothing covered that branch before, because it
sat among admin tests that only exercised the happy path.

The admin e2e app had to learn about the split. It assembles AdminModule alone,
so the moved routes 404'd there while working in production; it now imports the
three domains too. That is the same failure mode the module-provider guards
catch for RPC classes, and it is worth knowing that an e2e app which builds a
subset will find it first.

Also fixes the audit route note: audit-log stays on AdminController on purpose.
AuthModule imports AuditModule, so an admin-gated controller in AuditModule
closes a cycle — the same trap the addons toggles hit.
2026-08-09 20:54:39 +02:00
Maurice d4a1601855 Give the packing templates and the feature flags back to their domains
Twelve admin packing-template routes sat on AdminController and reached
PackingService through eleven pass-through methods on AdminService that did
nothing but forward and cast. PackingService already owns all three template
tables, so the routes move to AdminPackingTemplatesController next to it and the
pass-through block is gone. AdminService no longer injects PackingService at all.

The three places flags were raw app_settings SQL on AdminService, sitting beside
bag-tracking and collab-features which it already delegated to AddonsService.
They move to AddonsService, which owns every other flag, and AdminController now
calls it directly rather than through delegating methods.

The toggle ROUTES stay on AdminController, which is a departure from the plan and
worth stating: an admin-gated controller in AddonsModule makes that module import
AuthModule, and PluginGuardsModule imports AddonsModule precisely because it is a
leaf — Nest refused to build the graph. Same for the addon CRUD, whose PUT
cascades into PluginRuntimeService. Both notes are in the code.

Paths, response shapes, the {error,status} envelope, the create-201 split and
every audit action are unchanged.
2026-08-09 20:54:39 +02:00
Maurice 94221a6d91 Roll TripAccessGuard onto the remaining trip-scoped controllers
assignments, budget, collab, accommodations, reservations and booking-import
drop their copied requireTrip/requireEdit helpers for the guard. Status codes and
bodies are unchanged.

places is deliberately NOT converted, and that is the finding of this PR rather
than an omission. Its e2e suite documents that the body pipe answers 400 ahead of
the trip 404 — a parity shift somebody made on purpose — and a Nest guard always
runs before the pipe, so converting it silently reverses that ordering. The first
attempt also dropped the permission check on DELETE, which the integration suite
caught as a 200 where a 403 belonged. places keeps its in-handler check, and
README now says why so the next person does not rediscover it the same way.

collab applies the guard per handler for the same reason files does: its note-file
upload route would otherwise be refused before multer had read the body, and a
response sent mid-upload destroys the socket.

Two coverage notes. The per-domain thresholds are regenerated because deleting a
well-covered helper lowers a file's ratio without anything becoming less tested —
the drops are 0.4 to 1.2 points across six domains and match the helpers removed.
And the atlas integration suite gets the same admin1-bundle warm-up the unit suite
got: ATLAS-013 was paying 3.1s for the one-off decompression inside its own 5s
budget, which is why it only ever failed under load. It now runs in 116ms.
2026-08-09 19:46:00 +02:00
Maurice 8c0280e287 Replace the copied requireTrip helpers with a TripAccessGuard
Every trip-scoped controller carried its own three-line requireTrip, and a
requireEdit next to it: eighteen copies of a security check, each of which had to
remember that no access is a 404 'Trip not found' and never a 403 — the
distinction that stops a stranger learning which trip ids exist. One of them
getting it wrong would have looked like nothing.

TripAccessGuard resolves :tripId once, refuses what the user cannot reach and
hands the row to the handler through a @Trip() param decorator (a param
decorator rather than a req.trip cast, because the request type that used to be
widened for that is gone). @RequirePermission carries the same action string the
domain services pass to checkPermission, so the HTTP and MCP paths cannot drift
apart on which right they demand. Status codes and bodies are unchanged.

Piloted on days, day-notes, todo, packing and files. Two things worth knowing:

Files applies the guard PER HANDLER and leaves the upload route on its own
check. Guards run before interceptors, so a class-level guard would answer 404
before multer had read the multipart body, and a response sent while the client
is still streaming gets the socket destroyed — the caller sees ECONNRESET
instead of the 404. Refusing after the body has been read costs the bandwidth
and keeps the answer readable.

The services keep verifyTripAccess and canEdit: the large majority of their
callers are *.mcp.ts tools, which never pass through an HTTP guard. canEdit lost
its incidental controller-test coverage in the move, so it now has direct tests
asserting the action and the shared flag — the same pair the guard reproduces.

Also fixes the backup zip-slip test that failed only on CI: it asserted a
drive-letter path, which is absolute on win32 and an ordinary relative segment on
Linux, so the branch it was guarding never fired there.
2026-08-09 19:13:21 +02:00
Maurice fef338091c Fold backupService into the backup domain
src/services/backupService.ts moves to src/nest/backup/backup.impl.ts, imported
only by its own domain now that the controller reaches it through BackupService.
src/services/ is down to airtrail/.

It stays a module of free functions rather than becoming methods on the service.
The restore path closes and reinitializes the core DB handle, which is the single
most dangerous sequence in the server, and rewriting its shape in the same step
as the move would make a regression there impossible to bisect. The db-lifecycle
question is the last step of this fold, not part of it.

The move also pulled 512 lines out of ungated src/services/ into a gated domain,
which is how the per-domain ratchet caught that the per-entry extraction loop had
no tests at all: the zip-slip refusal, the running decompressed-byte cap (the
declared size in the central directory is attacker-controlled, so only the
running total catches a lying archive), a corrupt stream not being dressed up as
a size refusal, and the reopen failure that has to report restart-required rather
than success. Six cases now cover it, and backup goes from 84% to 96% statements.
2026-08-09 18:44:59 +02:00
Maurice 6680bc7c74 Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one
class, which made it the hub most of the Nest graph ran through: AdminModule
imported it for a single cascade-disable call and inherited all of it. It is now
PluginsRuntimeModule (supervisor, capability router, hook contracts),
PluginContributionsModule (the 14 read-only hook controllers, moved to
contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a
composition root that keeps the install and delivery controllers. AdminModule
imports the runtime alone.

The three plugin files that reached for the db singleton alongside the injected
DatabaseService no longer do. plugin-host-state and signature-status take the
connection from the caller, and the three per-user settings functions that were
bare exports 'so the RPC host wiring can call them directly' are now
PluginUserSettingsService - the host wiring is providers, so it injects them.

Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number
let a well-covered domain subsidise a thin one: booking-import sits at 50% and
integrations at 20%, and the aggregate still cleared the bar, so either could
have lost another ten points unnoticed. Each domain now carries its own measured
floor, regenerated by scripts/coverage-thresholds.mjs.

Two test-side findings on the way:

expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only
throws for a string needle. Every '<X> is listed in its module providers' guard
was written that way, so all twelve of them silently stopped guarding the moment
their class moved. They go through expectRegisteredProvider now, which asserts
the array shape first.

The atlas region test paid for the one-off decompression of the multi-MB admin1
bundle inside its own 5s budget, which is why it failed under load and passed
alone. The bundle is warmed in beforeAll instead.
2026-08-09 18:44:59 +02:00
Maurice dc81a5365e Make PluginsModule bring the whole plugin surface with it
With requireTotalCoverage on, a test app that assembles a subset of AppModule
now fails to boot: the admin e2e suite pulls PluginsModule in through
AdminModule, but WeatherModule was only ever reachable from AppModule, so
weather.get had no handler and validate() refused the start.

The invariant the gate needs is that importing PluginsModule pulls in every
domain that owns part of the wire surface. WeatherModule was the one domain
missing from that list.
2026-08-09 17:38:56 +02:00
Maurice f72aba0c91 Finish the plugin RPC decorator rollout
The last 20 wire methods move onto the decorators: db.* and the three
unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface
of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the
ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the
router is down to what was never domain-specific: build the granted subset of
the dispatch map, dispatch, audit, map errors.

PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With
the handlers in the domains it has two dependencies left, so it is now
PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router
bound with this plugin as the caller, and the audit sink.

The 18 host-to-plugin hook calls get the same treatment. They used to spell out
the fn name and the timeout at each controller, which meant nothing checked that
a hook:* grant on the consent screen had a consumer behind it, that the fn
matched what the SDK documents, or that two call sites agreed on the budget.
PluginHooks declares all 18 with @PluginHook and performs them, and the
controllers call it by name.

With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without
a handler, or a hook without a consumer, now fails app boot instead of surfacing
as a PERMISSION_DENIED nobody can explain.
2026-08-09 17:38:56 +02:00
Maurice 611f94e820 Consolidate the schema-error fallback and cover the defensive branches
Aiming the new files at 95% rather than at the 80% gate turned up something worth
fixing rather than testing around: `error.issues[0]?.message ?? 'bad input'` had
been copied into twenty handlers. That is twenty two-branch fallbacks that no test
can reach, because a real Zod failure always carries at least one issue. They are
now one schemaMessage() in rpc-params.ts, with one test that does reach both sides.

The rest of this is the branches the handlers inherited from the router: the
`!= null` link ternaries, the "service said no" paths, the ValidationError and
NotFoundError translations, and a reject case per schema-validated method.

Two limits found while measuring, both real rather than laziness. istanbul counts
each @PluginController line as a branch and only ever takes one side, so eight
branches across the rpc files can never be covered and a two-method file caps out
around 50%. And the three update schemas stay deliberately permissive, because the
migration's rule is parity over tightening, so their reject branch is defence in
depth no payload reaches. Everything else went from 50 uncovered branches to 29.
2026-08-09 16:38:43 +02:00
Maurice 294cfcd8d7 Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry
owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory
at 384, down from 944.

These five are the risky group, because their addon gates lived inside the deps
factory's closure rather than in the router. A handler that calls its service
directly still works, just without the check, and neither the compiler nor the
router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as
one table and asserts two things for each: with the addon off it is refused with
that addon's message, and the service is never reached. The table length is pinned,
so a later method added without a row is a failure rather than a silent gap.

One ordering detail worth knowing: on the atlas writes the router evaluated its
params before the addon gate inside the closure, so a malformed country code still
reports BAD_PARAMS rather than the addon. The handlers keep that order.

The collections service reports refusals as status-tagged errors rather than typed
ones, so mapCollectionError came across with the handlers: 403 and 404 become
RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a
HOST_ERROR instead of being dressed up as a permission problem.
2026-08-09 16:38:43 +02:00
Maurice 7bca741f28 Cover the fallback branches the moved handlers brought with them
The branch threshold over src/nest/** dipped to 79.89% after the move: the new
*.rpc.ts files carry the defensive branches the router closures had (?? '',
|| current, optional cascades), and none of them were exercised.

Adds the cases that reach them: a booking whose cascade does not fire, an update
that keeps the stored title, a bag colour of the wrong type, a non-array userIds,
a missing bag across all three bag writes, and the block delete without a partner
reservation. Also corrects two assumptions I had written into the booking tests:
title and type are required by the shared schema, so the handler's own fallbacks
sit behind it as defence in depth rather than as the first line.
2026-08-09 16:38:43 +02:00
Maurice 010242b107 Move costs, reservations and accommodations onto the decorators
Eleven more methods, so the registry owns 61 of 113. rpc-host.ts is at 771 lines,
down from 1332.

costs is the first addon-gated domain, and the addon check sits in a different
place in each of the two reads: inside the membership callback for getByTrip, so a
caller without trip access never learns whether the addon is on, and before
everything for listMine, which has no trip to check. Both orderings are asserted.
The writes keep their own refusal wording ("no permission to edit costs on trip N")
instead of going through requireTripEdit, which words it differently.

The booking paths are cascade-heavy and stay that way: creating a reservation can
create an accommodation and a budget item, deleting one removes both again, and
deleting a lodging block takes its partner reservation and budget item with it.
Each cascade is a broadcast the web app relies on, so they are asserted by event
name and order.

CostsRpc reaches the trip list through trips.bridge rather than by injection.
BudgetModule cannot import TripsModule, because TripsModule already imports
BudgetModule; budget.mcp.ts uses the same escape for the same reason.
2026-08-09 16:38:43 +02:00
Maurice 2ec6d006b0 Move the trip surface onto the decorators
Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332
lines to 830 and the deps factory from 944 to 500.

trips.update keeps its two field-level gates: archiving and changing the cover each
need their own admin-configurable permission on top of trip_edit, so a plugin
cannot do either on a trip it may otherwise edit. Member management keeps its own
grant for the same kind of reason, since adding a member grants trip access, and
the owner still cannot be removed through it because that would orphan the trip.

canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's
edit permission through it and has not moved yet.
2026-08-09 14:13:56 +02:00
Maurice c326d39a07 Move places, days and itinerary onto the decorators
Eight more methods, so the registry owns 38 of 113 and rpc-host.ts is under 1000
lines for the first time.

itinerary.* lives in assignments/, not in days/, because AssignmentsModule already
imports DaysModule and putting the handler the other way round would have closed a
cycle. It keeps riding on day_edit, which is what the app gates it on.

The delete path in places carries an ordering constraint that is silent when
broken, so it has two tests of its own: the journey hook has to run BEFORE the row
goes, because journey_entries.source_place_id is ON DELETE SET NULL and afterwards
there is nothing left to detach, and the place id has to be scoped to the trip
before the hook runs at all, because the hook keys on the place alone and would
otherwise detach another trip's entries even though the delete itself is refused.
2026-08-09 14:13:56 +02:00
Maurice d58f7aa399 Drop a dead expression from the files RPC test
Left over from writing the demo-mode case: a cast statement with no effect, which
eslint flags as no-unused-expressions. It is the one error in an otherwise
warnings-only run, so `eslint .` exits 1 and the Server Tests job fails on lint
before it runs a single test.
2026-08-09 13:24:58 +02:00
Maurice dbb0016cac Move the file surface onto the decorators
Six more methods, so the registry owns 30 of 113, and the deps factory is down to
660 lines from 944.

Files are the awkward corner of the plugin surface and stay that way on purpose.
Reading a file's bytes is a separate grant from reading its metadata, because a
passport scan is more sensitive than its filename, and the three writes use three
distinct rights instead of one domain action. FILES-RPC-006 pins that by watching
which permission each operation actually asks for.

This is also the only plugin path that touches disk, so the tests lean on it: the
extension blocklist, the size cap checked before the read rather than after, a
path that resolves outside the uploads root, and the demo-account upload refusal
that mirrors the REST guard.
2026-08-09 13:24:58 +02:00
Maurice ca88676858 Move packing onto the decorators and de-duplicate its privacy fan-out
Nine more methods, so the registry owns 24 of 113.

The #858 privacy logic existed three times: PackingService had the primitives, the
controller layered emitToViewers and broadcastUpdate on top privately, and the
plugin deps factory carried a standalone copy of all four, with a comment asking
for them to be kept in lockstep by hand. Migrating packing meant either copying
them a fourth time or fixing that, so the two private controller methods moved
into PackingService and the factory copy is gone. Controller and PackingRpc now
call the same code, and the controller suite proves it by passing with all 45
assertions unchanged.

The four public/private transitions get one test each, because each one is a way
to leak a private item to the whole trip room, plus a test that the privacy is
read before the write rather than after: reading it afterwards would report
"already private", skip the room delete, and leave the item on every member's
screen.
2026-08-09 13:24:58 +02:00
Maurice 62978aab5a Move five more domains onto the plugin RPC decorators
categories, weather, rates, todos and daynotes: 11 more methods, taking the
registry to 15 of 113. Handler bodies and messages are verbatim; the ledger test
tracks the split.

Two things the move surfaced rather than introduced. PluginGuards could not stay
in PluginsModule: that module imports 22 domain modules, so a domain importing it
back for the guards would close a hard cycle and the only way out would be
forwardRef. It now lives in a PluginGuardsModule leaf that imports nothing which
leads back, the same shape MailerModule got for AuthModule.

And the todo and daynote writes broadcast. Those events are not decoration, they
are what keeps an open session from showing stale data after a plugin write, so
they came across from the deps factory with the handlers, and there is a test
that a failed write broadcasts nothing.

Worth knowing for the next domains: todos are edited under 'packing_edit', not
under a todo-specific action, and TODO-RPC-003 pins that. Each new class also
asserts it is actually listed in its module providers, since a decorated class
nobody registers is invisible to discovery and answers PERMISSION_DENIED with no
other symptom.
2026-08-09 13:24:58 +02:00
Maurice 9b3d37ea29 Move the tags plugin surface onto the decorators
Wires the registry into the host and takes the first domain across, so the kit
lands with a worked example rather than as scaffolding nobody uses yet.

The registry is an additive, optional fourth constructor parameter on
PluginRpcHost, so all 107 existing construction sites keep compiling untouched;
that is what makes the rest of the rollout a per-domain move instead of a
big-bang one. bindInto runs LAST in the constructor, so a method still owned by
one of the has() blocks collides loudly instead of being silently overridden.

tags is the right pilot: four methods, a service that is already DI-native, and
it exercises the read/write permission split, the userless refusal and a
BadParams write. Handler bodies and messages are verbatim; only deps.foo(...)
became this.tags... and uid became ctx.actingUserId.

dispatch now strips the supervisor's _inv marker. The supervisor resolves the
acting user from it before dispatch and then passes the request on untouched, so
it has been visible to every handler and is in effect a reserved param name.
Nothing reads it: auditResource takes named fields only, and the one handler
that touches the params object as a whole (packing.setBagMembers) reads a named
field off it. Dropping it keeps a future strict parse from rejecting calls every
shipped plugin makes.

Two tests carry the rollout from here. rpc-coverage.test.ts derives both owner
sets from the code and proves they partition the method vocabulary exactly, so a
PR that moves a method and forgets to delete the legacy registration fails in
CI. And because a decorated class that no module registers is invisible to
discovery, with every call to it coming back PERMISSION_DENIED and no other
symptom, tags.rpc.test.ts asserts TagsRpc is actually listed in providers.
2026-08-09 11:58:56 +02:00
Maurice 6b6fbbd94c Lift the plugin RPC resource gates into an injectable
A decorated handler cannot reach the router's private tripRead / requireActor /
requireTripEdit, nor the canEditTripAs and requireAddon that live inside the
deps factory's closure. PluginGuards holds all of them so a *.rpc.ts class can
inject what it needs.

Every message is copied character for character, including the two asymmetries
that look like typos and are not: a trip read says "trip reads", while
requireActor appends the word "writes" to whatever noun it is given, and some
domains throw their own inline variant rather than calling it at all. The tests
pin the exact strings, not just the refusal, because shipped plugins read them.
2026-08-09 11:58:56 +02:00
Maurice 7b11680cc3 Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in
METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts,
with nothing asserting the two agree. protocol-paths.test.ts checks that each
method has some permission, never that it is the same one the router gates on,
and plugin-audit.ts inherits any drift silently.

@PluginMethod binds the two together. Its permission argument is typed as
MethodPermission<M>, so it resolves to the single literal the table assigns to
that method and a mismatch does not compile. validate() re-checks the same
argument against the same table at boot, because types evaporate under an as
cast. @PluginHook does the same against HOOK_PERMISSION.

UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the
router registers outside every grant check had no vocabulary of their own.
Adding it here, with a compile-time proof that it stays disjoint from
KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which
methods isAuditable considers, and settings.get is deliberately the one
unconditional method that is not audited.

The kit is written to stay extractable into its own package, so an ESLint rule
pins what it may import. Note the rule is a deny list on purpose: these patterns
are gitignore-style, and a leading ** would exclude the parent directory of
every allowed path, which makes the ! re-includes silently ineffective.
2026-08-09 11:58:56 +02:00
Maurice 8c5a207a9b Make the AI notice say who is accountable 2026-08-09 11:41:05 +02:00
Maurice 12ff2d3228 Move the AI notice up and trim it 2026-08-09 11:41:05 +02:00
Maurice 6f76c691da Say in the README how we use AI 2026-08-09 11:41:05 +02:00
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00
Maurice f0f69df67e Fold the journey domain into Nest and finish the MCP migration
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become
JourneyDomainService and JourneyShareService. Every SQL statement, error string
and broadcast is the one that shipped.

The two could never be split: the share service gates on isOwner, which lives in
the domain service, so it injects it.

JourneyDomainModule is a leaf carrying just the two services. Places,
assignments and the plugin host each need a handful of journey functions;
importing JourneyModule for that would drag MemoriesModule and both photo
providers into their graphs. Same shape as TrekPhotosModule.

The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is
down to _shared.ts and registerTools() no longer fans out to anything.

journey.bridge.ts keeps four exports, for the journey resources in
src/mcp/resources.ts that register at import time.

McpAccessMode is host-augmentable now. journey:share is neither read nor write,
so the three share tools would have had to be opaque predicates - and predicates
bypass the access policy entirely, invisible to the boot-time scope gate. A
scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry
mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'.

Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed
PlacesService with five of eight arguments. tsconfig only includes src, so
nothing typechecked it, and the missing dependencies were harmless until the
journey skeleton hooks landed on the place write paths.

journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy
registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25
cases for the skeleton lifecycle, the photo gallery and the preference row take
the domain service from 73.9% to 82.8%.
2026-08-08 23:25:57 +02:00
Maurice 86e2a96eb6 fix(client): a deliberate logout should not book a return ticket
Pressing "log out" clears isAuthenticated while the page you were on is
still mounted, so ProtectedRoute re-renders for it and sends you to
/login?redirect=<that page>. Log out from the dashboard and the next login
goes to the dashboard - beating the startup destination every time after
the first, which is exactly how it looked: worked once, never again.

A session that ended on its own still gets the return ticket, because
coming back to where you were is the whole point of that parameter. A
deliberate sign-out is a fresh start and now gets none, so the startup
destination decides. The Navbar already hinted at this with its unused
noRedirect state; the flag lives in the store instead, since all seven
call sites of logout() need it, not just that one.
2026-08-08 23:01:40 +02:00
Maurice 795cc50dae fix(client): stop the startup redirect racing the settings request
RootRedirect waited for whoever else might call loadSettings, which on a
cold start meant waiting for a request queued behind everything else a
launch fires off. Traced it in the browser: the settings came back as the
388th request, long after the redirect had given up and gone to the
dashboard. That is what "it still just opens the dashboard" was.

It now asks for the settings itself - the store de-dupes, so this costs
nothing when a load is already in flight - and login spends its 2.6s
takeoff animation fetching them instead of leaving that time idle, so by
the time it hands over they are already there.

Also folds the five copies of setShowTakeoff + navigate-after-2600ms into
one takeOff(), since all of them needed the same fix.
2026-08-08 23:01:40 +02:00
Maurice e7851e79ff fix(client): honour the start page on a device that never mirrored it
The mirror answered "dashboard" for a browser that had simply never been
told, and RootRedirect took that as the user's choice - so the setting was
ignored on every device except the one that set it, and on that one too
after a logout cleared the mirror. The setting itself was stored correctly
the whole time, which is what made it look like nothing happened.

readStartDestination now says "unknown" rather than guessing, and a device
in that state waits for the real settings instead. That costs one wait,
once per device; afterwards the mirror answers and the launch is as fast
as before. Bounded by a timeout, because loadSettings deliberately leaves
isLoaded false on a failed request so it can retry - without the floor, a
launch with no backend would sit on the spinner forever.
2026-08-08 23:01:40 +02:00
Maurice 0a824cec84 docs(wiki): document the startup destination and the tab deep link
Includes the tab ids the ?tab= parameter takes - they are the historic
German names, which is not something anyone would guess. That matters for
the people who asked: a shortcut or a browser wrapper can point at a tab
without the setting at all.
2026-08-08 23:01:40 +02:00
Maurice 6bf2e0708d feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in -
the On/Off button pairs on desktop, segments and a picker sheet on mobile.
The tab picker only appears once "Active trip" is chosen, since there is
nothing for it to mean otherwise.

Labels come from the same keys the planner's tab bar uses, so the picker
can never name a tab differently from the tab itself.
2026-08-08 23:01:40 +02:00
Maurice 44eff7177e feat(client): open TREK on the active trip when asked to
Settings -> General -> Startup: start on the dashboard (unchanged default)
or straight in the active trip, on a tab of your choice. Three clicks and
three loads less for someone entering expenses while actually travelling,
which is where the request came from.

The redirect sits on '/' because that is what a shortcut, a bookmark and
the installed PWA all launch (manifest start_url). /dashboard keeps
working as itself - redirecting there too would leave no way back to it.

The preference is mirrored into localStorage, the way the language and
the theme snapshot already are, so the first paint can decide without
waiting on GET /api/settings. That keeps the default launch exactly as
fast as before: only someone who asked for the trip pays for the lookup,
and if it fails or they have no trip they get the dashboard. Right after
login the store already holds the real settings, so those win there.

Login stops hardcoding /dashboard as its fallback and hands the decision
to the same place, so there is one answer to "where does TREK open".
2026-08-08 23:01:40 +02:00
Maurice 688ce0fae2 feat(client): let a trip URL open on a given tab
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in
the state initializer, not an effect, so the planner never paints the plan
view and swaps a frame later. The param is consumed on arrival; the
session memory keeps the choice across a reload.

Two things the tab machinery got away with while nothing ever started on a
tab other than plan:

- the guard evicted anything not in TRIP_TABS, but enabledAddons is an
  optimistic guess until the addon feed answers, and collab is guessed off
  - so a requested collab tab was thrown away a render before we knew the
  addon was on. It now waits for the feed, like it already did for plugins.
- the lazy loads hang off handleTabChange, which the tab we *start* on
  never goes through. Opening on Files showed an empty list, and that was
  already true for a tab restored from a previous visit.

Tab ids move to constants/tripTabs so the settings picker can name them
the way the tab bar does instead of keeping a second list.
2026-08-08 23:01:40 +02:00
Maurice 99ee307034 feat(server): expose the active trip as its own endpoint
GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.

That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.

Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
2026-08-08 23:01:40 +02:00
Maurice aca6c556ed test(server): cover the Synology provider
Completes the pass over the providers that moved into the gated tree. The
session handling is the part that most needed it: Synology invalidates a SID on
timeout (106), on a duplicate login (107) and when it does not recognise it
(119), and the service is supposed to clear it, re-login once and repeat the
call. That retry had no case at all — a regression there would have looked like
"Synology randomly stops working" and nothing would have failed.

Also pinned: a SID that no longer decrypts after a key rotation triggers a fresh
login rather than a dead session; a stored device id rides along so a trusted
device skips OTP; an app-level error that is not a session code is NOT retried;
the album listing merges personal, shared and shared-with-me, survives a partial
source failure, and collapses an id that appears in two sources; and an SSRF
block stays a 400 with the guard's own message while any other transport failure
is a 500.
2026-08-08 22:27:56 +02:00
Maurice c0d90d57b2 refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
2026-08-08 22:27:56 +02:00
Maurice 5b6c79299f docs(server): record what the coverage gate does and does not measure
PR #1844 went red with every suite green: the diff moved MCP tools from
src/mcp/, which the gate ignores, into src/nest/**, which it measures. That did
not change any behaviour - it revealed that airports.mcp.ts had never had a test
in either world, and dragged the aggregate to 79.86%.

Worth stating in the gotcha list, because journey.ts and _shared.ts still have
to make the same move.
2026-08-08 22:27:56 +02:00
Maurice 67c1633155 refactor(server): lift the memories vocabulary and the uploads anchor into nest
Two preparations the provider conversion needs, and neither changes behaviour.

The ServiceResult envelope, the provider-agnostic asset shapes and pipeAsset
move to nest/memories/memories.helpers.ts, which touches no database. The
controllers and the provider services can name them without importing each
other - the same reason notifications/notification-events.ts exists. What is
left in services/memories/helpersService.ts is the DB-backed half (the access
checks, the album-link lookups) and it folds with the providers.

pipeAsset stays a free function over the Express response on purpose. It is not
a StreamableFile candidate: it forwards the upstream's 206 and Content-Range so
a <video> can seek (#823), swaps Cache-Control to no-store on an error status,
and has to answer differently once headers are already sent. All three
controllers take @Res() anyway.

The three copies of `path.join(__dirname, '../../../uploads')` become one
UPLOADS_ROOT. __dirname moves with the file and none of these would have thrown
on a wrong depth - mkdirSync and multer create the wrong tree without a word,
and every stored photo lands somewhere nobody serves from. UPLOADS-ROOT-001
pins it. The depth happens to be identical for nest/memories, so nothing moves
today; the point is that the next move only has one place to get wrong.
2026-08-08 22:27:56 +02:00
Maurice aa4ed69677 refactor(server): give trek_photos its own repository
photoResolverService was two files in one. One half registers and drops rows in
trek_photos and touches no photo provider at all; the other dispatches a stream
or an info lookup to immich or synology. Keeping them together meant every
consumer that only wanted to store a row - the unified trip-photo service, the
journey service - dragged both providers in behind it.

The storage half becomes TrekPhotosRepository in nest/photos, over
DatabaseService. That is also where the photos/ vs memories/ line belongs:
storage here, provider dispatch with memories. photos.bridge.ts serves the two
modules still in services/ that register rows, and dies with them.

Every statement moved verbatim, including the two rules worth stating out loud:
getOrCreate re-encrypts a passphrase onto an existing row rather than inserting
a second one, and deleteIfOrphan never deletes a local photo, because its bytes
are ours and the file would outlive the row.
2026-08-08 22:27:56 +02:00
Maurice 7b666736df test(server): cover the MCP surfaces that moved into the gated tree
The coverage gate reads src/nest/** only, so the tools that lived in src/mcp/
were never measured. Moving them exposed what was never covered:

- airports.mcp.ts sat at 0% branches - search_airports and get_airport had no
  test at all, in either world
- the two weather catch blocks, which turn a provider failure into an isError
  result instead of a transport-level error
- the transport guards: trip access, day ids belonging to another trip, the
  not-a-transport-type check on update, and delete_transport in full
- the price path, where a transport with a price links a budget item and
  broadcasts budget:created

None of this is new behaviour; it is behaviour that shipped untested because it
sat outside the measured tree.

One case documents a subtlety worth keeping: the weather fallback message only
fires for a rejection with no .message at all. `new Error('')` still carries an
empty one and passes straight through, so the case rejects with a non-Error.
2026-08-08 21:20:36 +02:00
Maurice 3efedd5e48 refactor(server): decorate the last three MCP prompts
The prompts registrar is gone. Each prompt moves to the domain that owns its
data instead of a shared file that had to import three bridges to serve them.

packing-list goes to PackingMcp and budget-overview to BudgetMcp, both reusing
the `when` addon gate their tools already carry — so a disabled addon keeps the
prompt out of the listing, exactly as the `if (isAddonEnabled(...))` around the
registration call did. Both keep taking the trip title through trips.bridge:
TripsModule imports PackingModule and BudgetModule, so injecting TripsService
would close a cycle. Same documented in-container exception as atlas.mcp.ts, and
the registrar reached for it the same way.

token_auth_notice goes to a new AuthMcp — it is a statement about how the
session authenticated, not about trip data, and it injects nothing because the
message is static. Its `if (isStaticToken)` guard becomes `when: (ctx) =>
ctx.isStaticToken`; the registry hands `when` the session context, so an
OAuth 2.1 session never sees the prompt listed at all, which is what the
registrar achieved by skipping registration.

src/mcp/tools/ is down to _shared.ts and journey.ts. journey stays until
services/journeyService folds, which waits on memories.
2026-08-08 21:20:36 +02:00
Maurice cacab0795f refactor(server): decorate the weather, airport and transport MCP tools
Three of the four remaining hand-written registrars are gone. The tools keep
their names, descriptions, input schemas, annotations and result shapes; the
registration-time scope gates (`if (canWeather)`, `if (canGeo)`,
`if (!canWrite(scopes, 'reservations')) return`) become declarative access
markers, resolved by trekMcpAccessPolicy like every other decorated domain.

get_weather / get_detailed_weather move into a new WeatherMcp over the injected
WeatherService, including the catch that answers with the upstream message and
isError rather than throwing - a client sees "Weather service not available."
instead of a transport failure when the provider is down.

search_airports / get_airport move into a new AirportsMcp with no injected
service at all: the airport table is a static dataset. AirportsService owns the
flight-endpoint backfill against the database, which neither tool touches.

create/update/delete_transport move into ReservationsMcp rather than a domain of
their own, because a transport IS a reservation - same table, same service, same
reservation_edit permission - and that class already injects everything they
needed from the four bridges the registrar was importing.

That was budget.bridge's last outside-container consumer, so
linkBudgetItemToReservation is pruned with it; the file is down to one export and
dies as soon as BudgetMcp stops needing AuthService.

journey.ts stays: it still reaches into services/journeyService, which cannot
fold until memories does.
2026-08-08 21:20:36 +02:00
Maurice 790dd3b728 refactor(server): break the memories import cycle
immich -> unified -> photoResolver -> immich was a real cycle, every edge a
value import; journeyService only got past it with a dynamic import(). Nothing
in that cluster can become a provider while it stands, because Nest would need a
forwardRef through three modules.

It had exactly one cause: syncAlbumAssets and syncSynologyAlbumLink called
addTripPhotos, which lives in unifiedService. Both functions are two things
stapled together - fetch the album from the provider, then write it to the trip
- and only the first half is provider-specific.

So the providers keep the half they own (collectAlbumSelection,
collectSynologyAlbumSelection) and unifiedService keeps the half it owns
(syncImmichAlbum, syncSynologyAlbum). Every error string, status code and return
shape is the one the combined function produced, including the per-provider
wording the split had to preserve ('Could not reach Immich' stays in immich).

The plan called for pulling addTripPhotos and getOrCreateTrekPhoto into one
neutral module instead. Together they do not work as a pair: such a module still
needs immich and synology to fetch, so the cycle only moves.

There is a second cut that works - photoResolverService is really two files, and
its trek_photos CRUD half touches no provider, so splitting that out also opens
both loops with less churn. It is not the one taken here because it leaves
immich -> unified standing, i.e. a provider depending on the trip aggregate.
That direction is what tied the knot in the first place. The CRUD split is still
worth doing, but as the photos/ vs memories/ module boundary, not as the cycle
fix.

server/src/services/memories/ is a DAG now: helpers <- immich/synology <-
photoResolver <- unified.
2026-08-08 12:43:29 +02:00
Maurice 97f524cdfd feat(server): give the memories endpoints their Zod contracts
The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
2026-08-08 12:43:29 +02:00
Maurice ac96644508 refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot.

The mailer becomes its own leaf module. AuthService sends the password-reset
mail while NotificationsModule imports AuthModule for the demo gate in
NotificationsMcp - a mailer inside the notifications domain would have closed a
hard cycle that only a forwardRef could open again. Webhook and ntfy stay with
notifications; auth has no business importing them.

The channel registry stays a module singleton and does NOT become a provider.
PluginRuntimeService pushes its channel getter in at onModuleInit, and
notifications.bridge.ts builds its own NotificationsService for the scheduler
crons and the memories senders. A provider would hand the bridge a second, empty
registry - every plugin channel would then go quiet with no error anywhere.
CHOVR-015 drives that seam end to end: a channel published through the runtime,
delivered through the bridge. Nothing pinned it before; the existing cases all
set the source themselves.

The built-ins no longer self-register by side-effect import. They used to exist
only because notificationPreferencesService happened to `import
'./notifications/builtins'`; removing that line would have silenced email,
webhook and ntfy without a failure. They are built from the injected transports
and registered from NotificationsService's constructor, so both instances have
them.

No cached nodemailer transport: MailerService.createTransport re-reads the SMTP
config and skipTlsVerify per send, exactly as the three inline call sites did,
so an admin changing them does not have to restart.

Preferences become a provider over DatabaseService. Eight places built
NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps
the next constructor parameter a one-file diff instead of an eight-file one.
2026-08-08 12:09:56 +02:00
Maurice 712a33626a refactor(server): give the notification vocabulary its own module
The event names, the channel ids and the ExternalChannel contract move to
nest/notifications/notification-events.ts, which imports nothing.

The cluster only looked acyclic: notifications.ts and channelRegistry.ts both
reached back into notificationPreferencesService for NotifEventType, and both
edges were `import type`, so they compiled away. Putting any of those names in a
value position - an enum, a const array, an injected class - would have made the
cycle real, with builtins.ts as a third ring. Nothing can fold while that is
true.

The drift guard against the shared NotificationEventKey moves with the union it
guards; it sat three files away from it.
2026-08-08 12:09:56 +02:00
Maurice 03493a2edd refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
2026-08-08 11:14:24 +02:00
Maurice 5eab8128bd refactor(server): fold the ephemeral-token store and the in-app action registry (#1836)
Two small leftovers, moved to the domains that own them.

ephemeralTokens holds the short-lived tokens the WebSocket handshake and the
file-download links use. It already had startTokenCleanup/stopTokenCleanup with
an unref-ed handle — the right shape. What was missing is anyone calling stop:
index.ts kicked the sweep off with a bare require() after listen() and nothing
ever ended it. AuthService owns that lifecycle now, through
onModuleInit/onModuleDestroy, which is where it belongs — it is the service that
issues the tokens.

The store itself stays module state. websocket.ts consumes tokens from outside
the container, and a token store is only useful if the issuer and the consumer
share one. Same reasoning as the weather cache in #1835.

inAppNotificationActions is a 22-line handler registry with exactly one
consumer; it moves next to it as notifications/in-app-actions.ts.

services/ is down to 9 files, from 44.
2026-08-07 22:18:14 +02:00
Maurice a7d789fdd5 refactor(server): move weather out of services/, give its cache sweep a lifecycle (#1835)
Finishes be-weather-airports-fold. The README has listed weather as the
reference implementation since the migration started, while 513 lines of it sat
in the legacy layer.

The file is nest/weather/weather.impl.ts now, and WeatherService owns what it
could not own before: the cache sweep. That sweep was a bare setInterval at
module scope, so every process that so much as imported the file started a
five-minute timer and nothing ever stopped it — there was nothing to call. It is
startCacheCleanup/stopCacheCleanup now, driven by onModuleInit/onModuleDestroy,
and the handle is unref-ed so a cache sweep can never hold the process open.

The old test file says as much: its second line is "Prevent the module-level
setInterval from running during tests", with a vi.useFakeTimers() to do it. That
workaround is no longer what keeps the timer quiet.

The cache itself stays module state, deliberately. The MCP weather tools call
getWeather directly from outside the container, and a cache is only worth having
if every caller sees the same one. Hoisting it into the class would mean handing
the registrar the container instance to get exactly what a module singleton
already provides — the same trade the place-photo cache resolved the other way,
because there the scheduler needed the instance anyway.

Coverage is the reason this PR carries eleven new tests rather than none. Code
in services/ is ungated; the moment it lands in src/nest it counts against the
80% branch threshold, and weather.impl arrived at 75%. The gap was in paths
worth having: a failing archive lookup with and without an upstream reason, a
far-future date whose climate rows are all null, an expired entry being re-
fetched, and the sweep evicting one on its own. Plus the lifecycle itself —
start is idempotent, stop is idempotent, the handle is unref-ed. 79% now.

services/ is down to 11 files, from 44.
2026-08-07 21:47:21 +02:00
Maurice 9c46f31c57 refactor(server): fold airports and distance, drop the boot-time backfill cycle (#1834)
Two thirds of be-weather-airports-fold. weatherService (513 LOC, a process
cache and a setInterval at import) is left for its own PR.

airportService split along the line that was already there. The dataset lookup
touches no database — it reads one JSON file and keeps it in memory — and both
MCP registrars call it from outside the container, so findByIata/searchAirports
are free functions in nest/airports/airports.data.ts. backfillFlightEndpoints
needs the connection and became a method on AirportsService.

That backfill was the interesting part. db/database.ts ran it at module eval
through a bare require() back into services/, so the module graph closed a cycle
that only held because `db` happened to be initialised by the time the require
resolved. It runs in onApplicationBootstrap now: it repairs rows, nothing boots
on it, and the container is fully built by then.

The cycle had a visible cost. tests/setup.console-noise.ts carried a suppression
for `Cannot find module '../services/airportService'`, because vitest's
transform pipeline could not resolve that require at all — it fired in roughly
sixty test files. That suppression is now narrowed to the only case left: an e2e
TestingModule whose fixture DB has no reservations table.

distanceService split the same way: haversineKm is arithmetic and moved to
common/geo.ts; getFlightDistanceKm reads the database and became a private
method on AuthService, its only caller.

AirportsModule imports DatabaseModule explicitly. @Global only reaches modules
that are in the graph, and the airports e2e builds its container from
AirportsModule alone — exactly the trap the nest README now documents.

The search ranking gets its first direct tests. It sat in services/, outside the
coverage gate, and only ever ran incidentally through the route; moving it into
src/nest made the gap measurable (16% branches). The ten cases pin the scoring
ladder — exact IATA short-circuit, ICAO over prefix, IATA prefix over city
prefix, city prefix over substring — and the missing-dataset fallback, which no
test had reached. 90% branches now.
2026-08-07 21:07:14 +02:00
Maurice c7fed4b1f8 refactor(server): make the place-photo cache a provider (#1833)
The last file in be-places-helpers-fold, and the one that had to be a provider
rather than a move: it carries three in-memory structures that only do their job
if the process has exactly one of them.

  recentFailures  stops a screenful of markers hammering an API that is down
  inFlight        stops a stampede when several requests miss the same placeId
  knownOnDisk     saves an existsSync per cache hit

A second instance gives the stampede guard its own inFlight map, so two
simultaneous requests for the same uncached place both call the provider. That
is why the scheduler takes this one from the container instead of constructing
it: SchedulerDeps gains a placePhotos entry alongside backups, filled from
app.get() in index.ts — the shape PR #1827 was built for.

The module-scope mkdirSync becomes onModuleInit, and GOOGLE_PHOTO_DIR is read
per call through RuntimeEnvService rather than frozen at import. That is not
cosmetic: TREK_PLACE_PHOTO_DIR is the seam the tests use to keep off the real
uploads tree, and its own test used to have to import the module dynamically to
set the variable in time. The __dirname fallback moved a level with the file and
is corrected accordingly.

Consumers: maps, places and share inject it; places.helpers.reclaimPhotoCache
takes it as a parameter, since it is a free function.

Six test seams follow. Four path mocks become injected stubs with the same mock
functions behind them; the collections case keeps the REAL implementation,
because what it asserts is that removeIfUnreferenced counts collection_places as
a reference (#1081) — a stub there would assert nothing. The share e2e overrides
the provider in its container.

services/ is down to 14 top-level files, from 44 when this started.
2026-08-07 20:27:19 +02:00
Maurice 4e6b45016a refactor(server): move the KML importer and place-image helpers into places/ (#1832)
Two of the three files in be-places-helpers-fold. placePhotoCache is left for
its own PR — it holds three module-level caches and its sweep hangs off the
cron, so it needs both a provider with real state and a second entry in the
scheduler contract.

kmlImport.ts is 212 lines with no state, no database and no I/O — parsing,
sanitising and coordinate arithmetic. It is nest/places/kml-import.helpers.ts
now, unchanged, next to the places.helpers.ts that already consumes it.

placeImage.ts moves to nest/places/place-image.ts as free functions. Making
reclaimPlaceImage a provider would have split it from PLACE_IMAGES_DIR, which
cannot be injected: nest/common/place-image-upload.ts reads it at module scope
to configure multer's storage, before any container exists.

That constant is the one thing here that could have broken quietly:

  path.resolve(__dirname, '../../uploads/places')

__dirname moves with the file. From src/services that resolved to
<server>/uploads/places; from src/nest/places the same string would have
resolved to <server>/src/uploads/places — a directory multer would have happily
created, leaving every uploaded place image in the wrong place and every
existing one unreachable. The path is now '../../../uploads/places', verified to
resolve to the identical absolute directory, and uploads-dirs.test.ts covers it.
2026-08-07 19:58:12 +02:00
Maurice 90e35e6970 refactor(server): fold llmConfig and unsplash out of services/ (#1831)
Two of the three leftovers in be-misc-fold. The third (wikiService) needs a Zod
contract in @trek/shared first and is left for its own PR.

llmConfig.ts was pure movement: 70 lines of encryption-aware config shaping with
no state and no database, sitting in services/ while all four of its callers are
in src/nest. It is nest/llm-parse/llm-config.ts now, same functions, same file.

unsplashService.ts needed an actual provider. Its key resolution reads both the
env and the users table (env var, then the caller's stored key, then any
admin's), so the injected connection was unavoidable. Its own module because
trips and places both reach it, and because folding it into either would have
made the other import a trip/place service for an image search.

The four exports became methods on UnsplashService. The SSRF guard in
saveUnsplashCover is untouched — it still refuses any host but the Unsplash
image CDN and still goes through safeFetch — as is the unauthenticated-endpoint
fallback when no key resolves (#1449).

The env read stays live through RuntimeEnvService rather than a registerAs
token. readEnv() is what the function did before, and a boot-frozen token would
change when an operator's UNSPLASH_ACCESS_KEY takes effect.

Two test seams follow the code: the unsplash unit tests bind the four methods so
every existing assertion reads unchanged, and the trips controller's path mock of
the module becomes an injected stub.

services/ is down to 17 top-level files.
2026-08-07 19:34:31 +02:00
Maurice 7dea88120b docs(server): bring the nest README up to the foundation work (#1830)
The README is the binding spec, and seven PRs have landed patterns it does not
mention. Someone reading it today would rebuild things that now exist, and would
walk into two traps that cost real time this week.

Added to the foundation section: common/ and why those helpers are free
functions, auth/jwt-verify.ts and why JWT_SECRET must not move to app-config,
query-helpers/, trip-membership/ and the cycle that put it in its own module,
AddonGuard + @RequireAddon with its ordering requirement, and demo-write.

New subsection on reaching the container from outside it. The choice is settled
now: hand the dependency in, declared structurally, the way index.ts fills the
scheduler's SchedulerDeps. Bridges stay correct for the MCP registrars, but the
README should say what they cost — a bridge rebuilds its services with `new`, so
one added constructor parameter meant editing five bridge files in a single PR.

Two gotchas that are cheap to read and expensive to rediscover:

  Never gate a multipart upload with a guard. Guards run before the parser, so
  throwing leaves the body unread and Node resets the connection — the client
  gets ECONNRESET rather than the 403.

  @Global only applies to modules that are in the graph. An e2e TestingModule
  built around one domain module does not get AppConfigModule for free, and a
  provider injecting RuntimeEnvService takes every suite importing that module
  down with it.

Also corrects an assumption the migration plan makes and this README implied:
the trip-access and canEdit methods on the domain services are not dead weight
waiting for TripAccessGuard. Across the five domains piloted for it, 40 of the
46 callers are *.mcp.ts tools, which never pass through an HTTP guard. Only 6
sit in controllers. Counted, not estimated.
2026-08-07 19:04:20 +02:00
Maurice 22d178d2f9 refactor(server): one addon guard, one demo-mode condition (#1829)
Two pieces of copy-paste in the guard layer.

Three addon guards — collections, airtrail, journey — did the same thing and
differed only in the addon id they read and the word leading the 404. They are
one AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through
the Reflector. A handler-level decorator overrides the controller's, and a route
without the decorator is not gated at all.

The demo-mode upload block was copy-pasted into six handlers, each carrying its
own copy of the message. All six call isDemoWriteBlocked now, against the same
DEMO_WRITE_ERROR body.

That one is deliberately NOT a guard, though all six sites are handlers and a
guard is the obvious shape — and this PR did build one first. Guards run before
the multipart parser, so throwing from one leaves the request body unread and
Node resets the connection: the client gets ECONNRESET instead of the 403.
PROFILE-015 caught it. The check has to sit inside the handler, after the
interceptor has drained the upload, for the response to arrive at all. The
function carries that reasoning so the next person does not rediscover it.

Two smaller findings on the way there.

The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode:
AuthModule listed it as a provider, and RuntimeEnvService was not resolvable
inside a TestingModule built around one domain module. AppConfigModule is
@Global, but @Global only applies to modules that are in the graph. The five
modules whose controllers now inject RuntimeEnvService import it explicitly, the
same way PermissionsModule is handled.

And both new guards get a test asserting the decorators are actually applied.
Once a check moves out of a handler, that handler's own tests stop noticing if
the decorator is dropped — the assertions on __guards__ and the @RequireAddon
metadata are what keeps the four upload endpoints and the four addon route
groups gated, and gated on the right addon.
2026-08-07 18:42:53 +02:00
Maurice 9814480a96 refactor(server): move the JWT verify into nest/auth, delete four dead middlewares (#1828)
middleware/auth.ts exported seven things. Two of them — extractToken and
verifyJwtAndLoadUser — are the canonical session check every auth surface goes
through. The other five (authenticate, requireCookieAuth, optionalAuth,
adminOnly, demoUploadBlock) are Express middlewares with no production caller
left; the guards in nest/auth replaced them and only comments still mention
them.

The two live functions move to nest/auth/jwt-verify.ts and the file goes, along
with middleware/validate.ts (nothing but its own test imported it) and
middleware/idempotency.ts (its only caller was `authenticate`, and
IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go
with them — src/typings/express.d.ts is what types req.user now.

Eight importers repoint: the three guards, auth/files/plugins-proxy services,
platform.routes and the global MFA policy.

Two deliberate departures from the plan for this package.

JWT_SECRET does not move to app-config. It is not an env value: it is generated
into data/.jwt_secret, and the admin panel rotates it at runtime through
updateJwtSecret. `export let` is precisely what makes a rotation take effect
in-process — a registerAs token would freeze the boot value and silently break
rotation.

The verify also keeps reading the db singleton rather than DatabaseService. That
change is not a one-line swap: it gives the three guards a constructor
dependency, which means registering them as providers everywhere they are used.
Measured: 38 directories use those guards and 21 of them do not import
AuthModule, on top of 34 e2e tests that each build a container around a single
domain module. That is its own PR, not a rider on one that deletes dead files.

tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply
deleted. Its extractToken cases and the five verify cases it drove through
`authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting
verifyJwtAndLoadUser directly. Four cases are new: the password_version gate —
the whole reason this function exists — had no direct coverage at all. Its three
adminOnly cases are dropped because AdminGuard, their successor, is covered by
three e2e suites.
2026-08-07 18:01:24 +02:00
Maurice a634b2a26b feat(server): give the scheduler a contract with the container (#1827)
The scheduler runs outside the Nest container but needs four things inside it:
backupService, trekPhotoCache, placePhotoCache and airtrailSync. It reaches all
four with a require() or await import() at call time. This settles how that
should work before four separate folds each invent their own answer, and wires
one consumer as proof.

It takes the dependencies from the container rather than growing four more
bridges, for a reason that showed up while writing the previous PRs: adding one
constructor parameter to a service meant hand-editing five bridge files that
rebuild their services with `new`. app.get() has no such cost, and hands back
the container singleton instead of a second instance.

The scheduler declares what it needs structurally — a `backups` with a
createBackup — and never names BackupService. That is not fastidiousness: an
import of the class would pull in src/nest/backup → src/services/backupService →
src/scheduler, which is the exact cycle the lazy `await import()` in runBackup
existed to dodge. With the dependency handed in, the scheduler stops importing
backupService entirely and the cycle is gone; backupService still imports the
scheduler for VALID_INTERVALS, but now only in one direction.

index.ts fills the contract from the app buildApp() already initialised, right
before the first cron starts, the way bootstrap.ts hands the /mcp handler its
registry.

Two smaller things fall out. The Nest BackupService was swallowing
createBackup's prefix parameter, so it could only ever produce 'backup' names;
it forwards it now. And the scheduler can be started without a container at all
— its unit tests do exactly that — so runBackup says so in the log instead of
throwing, with a case covering it.

The other three consumers stay on their require() calls; they move with their
own folds.
2026-08-07 17:29:53 +02:00
Maurice 1e05501b9d refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.

45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.

forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:

  NumericInput   — a plain DOM ref, no imperative handle involved
  JourneyMap     — useImperativeHandle stays, only the wrapping goes
  JourneyMapGL   — same
  JourneyMapAuto — same

Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.

The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.

The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.

TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.

Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
2026-08-07 17:03:47 +02:00
Maurice eb8e777741 chore(client): move zustand to 5.0.14 (#1825)
The last React-18 leftover in the dependency tree. v4 declared
use-sync-external-store as a hard dependency and its main entry imported the
shim; v5 declares no dependencies at all and calls React.useSyncExternalStore
directly. Only zustand/traditional still reaches for the shim, and nothing here
imports it, so it no longer reaches the bundle.

The upgrade itself needed one line. What it needed more of was checking, because
the one thing v5 changes about behaviour does not show up as a test failure.

v4 subscribed through useSyncExternalStoreWithSelector, which memoised the
selector's result. v5 hands React a plain getSnapshot of
`() => selector(api.getState())` and compares with Object.is. A selector that
builds a fresh object or array therefore returns a new reference on every call,
and React reads that as "the store changed" — an infinite render loop, or at
best a re-render on every parent render.

All 294 store selectors return a property or a primitive. None builds an object
literal, array literal, filter/map/slice result, `?? []` default, Object.keys or
a new Set/Map, so none of them needed useShallow. The suite confirms it: 533
files, 11,165 tests, and zero "getSnapshot should be cached" or "Maximum update
depth" warnings anywhere in the output.

The one source change is the seedStore test helper, which was typed against a
hand-written `{ setState: (partial, replace?: boolean) => void }` shape. As of
v5 setState is an overload pair — partial with replace?: false, full state with
replace: true — and no single signature is assignable to that. Typing it against
zustand's own StoreApi fixes all 129 resulting errors and is what the helper
meant in the first place.
2026-08-07 16:34:18 +02:00
Maurice 939452f9d8 refactor(server): consolidate trip access and fold the query helpers into the container (#1824)
* refactor(server): route every trip-access check through DatabaseService

Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.

All twelve now take the injected route, so services/tripAccess is gone.

The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:

  NonNullable<ReturnType<typeof verifyTripAccess>>   6x
  { user_id: number }                                4x
  TripAccess                                         3x

The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.

middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.

* refactor(server): fold the query helpers and trip joins into the container

Two more legacy files whose only reason for staying outside the container was
that they predated it.

services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.

The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.

services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.

Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.

Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.

ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.

Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
2026-08-07 16:00:12 +02:00
Maurice 6dda05f672 refactor(server): move the stateless helpers into nest/common (#1823)
Eight files in services/ hold no state and touch no database: avatarUrl,
conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two
crypto helpers. They are imported from 65 places, 62 of them already inside
src/nest, so having them live in the legacy layer was pure inertia.

They stay plain functions rather than becoming providers. Three importers sit
outside the container and would have no way to reach an injectable one:
db/migrations.ts runs before the app is built, and middleware/auth.ts and
middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony
and nothing else.

mfaCrypto and apiKeyCrypto move together into common/crypto — they read the
same ENCRYPTION_KEY, and the directory says so.

services/ drops from 44 files to 36, 9,870 lines to 8,493.

One trap worth recording: src/app-config and src/nest/app-config both exist.
cookie.ts imported '../app-config' from services/, which resolved to the former;
after the move the same string silently pointed at the latter, which has no
readEnv. It is now explicit about which one it wants.

Nothing else changes — no signatures, no behaviour, no tests rewritten beyond
their import paths.
2026-08-07 15:14:14 +02:00
Maurice 3dfbb3fd02 perf(planner): load the PDF export and the transport modal on demand (#1821)
Two more things every trip paid for whether or not anyone used them.

downloadTripPDF hung statically off the days sidebar, so opening any trip
pulled in the whole PDF builder. Both call sites are already async and already
wrap the call in try/catch, so a failed chunk surfaces as the export error the
handler had anyway.

TransportModal was already rendered conditionally, which means lazy bites
immediately. It is worth more than its own 32 kB: it is the only path to
TransitSearchPanel, and that chunk carried tz-lookup — about 200 kB of packed
zone geometry.

  TripPlannerPage      339.78 -> 305.70 kB
  entry chunk          258.24 -> 218.70 kB
  TransitSearchPanel   198.67 ->  26.51 kB   (and now on demand)
  TripPDF              226.02 ->  27.78 kB   (and now on demand)
  eager path           unchanged at 1,483 kB

Worth stating plainly: tz-lookup itself did not go away. It sits in
PlaceFormModal.helpers now, and PlaceFormModal is still a static import of the
planner, so a trip still loads it. Prying that loose means touching
placeOpenState, whose consumers assume a synchronous lookup — a separate change
with its own care.
2026-08-07 14:38:25 +02:00
Maurice cf62567c48 fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown

Tearing down the reservation overlay logged an error on every map unmount:

  Source "trek-reservations" cannot be removed while layer
  "trek-reservations-lines-transit-casing" is using it.

The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.

The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.

The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.

* fix(map): stop untrusted values reaching the marker HTML

Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.

place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like

  /uploads/x" onerror="…" y="

satisfies that and then breaks out of the src="…" attribute.

category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.

Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.

Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.

The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.

Verified against the unpatched renderer — the three new cases fail without it.

* perf(client): serialize the map icons without react-dom/server

Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.

  mapViewport chunk   189,532 -> 3,710 B
  Tooltip chunk       194,333 -> 1,970 + 4,860 B
  precache total      17,856 -> 17,675 KiB

utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.

Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.

TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.

An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
2026-08-07 13:43:38 +02:00
Maurice c089e75f08 perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both

MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and
maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both.
Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then
ran exactly one of them. The 110 kB stylesheet was merged the same way.

  mapbox users     2,827,647 -> 1,797,952 B   (-1.03 MB, CSS 110.6 -> 40.8 kB)
  maplibre users   2,827,647 -> 1,027,635 B   (-1.80 MB, CSS 110.6 -> 69.8 kB)

The engine is a prop now rather than a module import, and the pairing happens
behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically
any more, which is the whole mechanism — the components themselves stayed
engine-agnostic, they already guarded on the provider everywhere the two
genuinely differ.

Both imports of a pair start together instead of in sequence: the engine is by
far the larger half, and fetching the component first would put its round trip
in front of the download that actually costs time.

check:gl-split guards it. A stray static import anywhere in the map tree brings
the merged chunk straight back with a green build and a working app, so the
script greps the built chunks for a marker of each SDK and fails when one file
holds both. Not on the bare string "maplibre" — the provider value and the
maplibre_style setting key appear in app code too, so that matches the shared
core chunk as well.

Two boundaries were missing and are added here, because the split makes them
reachable: the GL boundaries had no resetKeys, so a chunk failure under one
provider kept showing Leaflet after switching to the other, and the settings
preview had a Suspense but no boundary at all — that is the one place a user
flips providers live, so it is the likeliest chunk to fail.

Worth being precise about the gain: the service worker precaches every chunk,
so an installed PWA still downloads both engines. What this buys is the
critical path on a cold cache, and parse/compile/execute always — a megabyte
of unused engine ran on every map open.

* perf(client): split out the video player and the ISO subdivision data

Two heavyweights that shipped with routes that rarely use them.

plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and
through them off the FileManager, JournalBody and MTripShell chunks. The
lightboxes only render the player on their video branch anyway, so the split
costs one frame of placeholder — a black rectangle in the target geometry, so
the lightbox does not jump when the chunk lands.

iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and
only fetchRegionOptions touches it. It now loads after the hasRegions check,
so it is fetched only for countries that get a region picker at all:
holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing
try — a failed chunk yields the same empty array as a failed request, rather
than an unhandled rejection in the two callers that only do .then(setRegions).

tz-lookup is deliberately left alone. TransitSearchPanel builds the departure
time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a
wrong query rather than a late pixel — that one needs its own change with its
own care.
2026-08-07 12:46:03 +02:00
Maurice 49e68fb07c build(client): give react and the core libs their own chunks (#1818)
Everything that changes with a release currently drags the entry chunk with
it, and 63 of 141 chunks import the entry statically. Measured with a
one-token edit to a leaf route module and a rebuild, 70 of 141 chunks rehash —
just over 1 MB gzip that every installed PWA re-downloads per deploy, since
the service worker registers with autoUpdate.

Pulling react and the core libraries out cuts that to 847 kB:

  rehashed   70/141 -> 60/140
  gzip       1,038,497 B -> 847,688 B   (-18.4%)
  entry      580,672 B -> 258,105 B
  eager JS   1,482,722 B -> 1,482,523 B (unchanged)
  eager CSS  same file, same hash

No first paint win, then — the gain is a smaller update delta and a halved
entry to parse on weak devices.

Three of the five groups the plan called for are left out, and not silently:
mapbox-gl/maplibre-gl, leaflet and react-markdown already sit in async chunks
whose hashes survive a release, so a group would only rename them. Worse,
without tags: ['$initial'] a group also collects modules that hang behind
React.lazy today and turns its chunk into a static import of the entry —
measured, that put the 2.8 MB GL chunk into the index.html modulepreload and
took eager JS to 4.61 MB, undoing the route split for maps.

Note for the next person to touch this: vite 8 bundles with rolldown, so
rollupOptions is only an alias and both manualChunks and advancedChunks are
deprecated in favour of codeSplitting.groups. A config using the old names
builds green and has no effect.

Also refreshes two comments that still quoted pre-split bundle numbers.
2026-08-07 12:01:43 +02:00
Maurice 057d4ba503 perf(client): pick the viewport branch at the route (#1817)
Every page imported its mobile screen statically and chose while rendering, so
each route chunk carried both trees and the viewport only decided which half
stayed dark. Worse, because ten route chunks shared the mobile tree, rollup
hoisted it into the entry chunk — so every desktop user downloaded the whole
mobile shell before seeing a single page.

The branch now decides the chunk. A phone never fetches the desktop planner, a
desktop never the mobile shell.

  entry chunk        1,243 -> 581 kB   (gzip 349 -> 177)
  TripPlannerPage      914 -> 340 kB
  VacayPage            361 ->  57 kB
  AdminPage            413 -> 225 kB
  SettingsPage         259 -> 155 kB

Splitting inside each page instead was measured and rejected: it leaves the
desktop code in the same module as the switch, so a phone would still download
it and then wait for a second, serial request on top — 914 kB and a waterfall
against 550 kB in parallel.

The pass-through wrapper in each page stays deliberately. check-page-pattern
scans from the default export to the next top-level function and rejects hooks
there; making the desktop component the default export fails it immediately on
dashboard, planner and atlas.

RouteFallback gets a mobile face. It renders above MobileShell, so the --m-*
tokens do not resolve for it and the desktop spinner on bg-surface would be a
foreign white sheet in front of the mobile screen.

The five scattered viewport spot checks are replaced by one contract covering
all eleven routes, which is where the switch now lives.
2026-08-07 11:51:37 +02:00
Maurice 1d94814b57 perf(planner): load the tab panels on demand (#1816)
* refactor(costs): move the pure split maths out of the panel module

splitEqualShares, calculateTicketShares and TicketItem sit next to the payer
maths they belong with. They are pure functions with no module state and no
imports from the panel, so this is a move and nothing else.

The reason it matters: the mobile cost sheet and its model import these two
functions from CostsPanel, and MTripShell is a static import of the trip
planner page. Any attempt to load the costs panel on demand would therefore
pull the whole 97 kB module straight back in through the mobile branch.

* perf(planner): load the tab panels on demand

Each tab panel only mounts while its own tab is active, yet all six shipped in
the planner chunk. The page was 1,203 kB after route splitting — larger than
the entire entry chunk. It is now 914 kB, with the panels in six chunks of
their own (files 99, collab 87, packing 71, costs 62, reservations 32,
todo 31 kB).

The panels also get the error boundaries they never had. fe-error-boundary
covered the maps and the plugin frame but not these, so without them a dead
panel chunk would throw past to the route boundary and take the whole planner
with it. The boundary sits outside the Suspense, since Suspense only owns the
pending promise and a rejected one throws straight past it.

One boundary per panel rather than one around the content area: a shared one
would already be mounted with the visible tab, so switching tabs would swap
the entire planner for the placeholder instead of just the part still loading.

PluginFrame stays static — DayDetailPanel and PlaceInspector import it from
the plan tab anyway, so splitting it here would move nothing. The two import
modals stay static for the same reason, via the mobile sheets.

The wiring tests now await their panel. That includes the ones still passing:
React.lazy caches the resolved module for the whole file, so they were only
green because an earlier test had already pulled it in.
2026-08-07 10:37:10 +02:00
Maurice e5962c22d5 chore(client): tighten the precache ceiling and correct the offline docs (#1815)
* chore(client): tighten the precache ceiling now the entry chunk is split

The 6 MB ceiling was set against the pre-split bundle, where the entry chunk
alone was 5.2 MB. It is now 1.2 MB and the largest precached entry is the
heic-to chunk at 3.0 MB, so the old limit had nothing left to catch.

Worth being explicit about what the limit does: a file above it is dropped
from the manifest and the build still succeeds, printing only that the file
will not be precached. The PWA is then quietly broken offline for that file,
which is why the ceiling has to stay close to the real bundle.

Also records why every route chunk is precached rather than pulled in at
runtime, since that is the reason route splitting did not shrink the install.

* docs(wiki): correct the offline cache table

The table still described an api-data cache holding API responses for 24
hours under NetworkFirst. That cache is gone: API requests are NetworkOnly
because Workbox keys entries by URL and cannot vary them on the session
cookie, so on a shared device one account's data could be served to the next.
Documenting a cache that deliberately does not exist is the wrong way round
for a privacy decision.

Map tiles were listed at 1 000 entries; the actual cap is 12 288 and has to
stay in step with MAX_TILES in tilePrefetcher.ts. The two GL tile caches were
missing altogether, and the precache line now says that it covers every page
rather than just the shell.
2026-08-07 10:25:44 +02:00
Maurice 96e4ff5932 perf(client): load the page chunks on demand (#1812)
* feat(client): retry a failed page chunk once before giving up

A dynamic import fails for two different reasons, and only one of them is
worth a second attempt. If the file is gone because a deploy happened while
the tab was open, nothing but a reload helps and reloadOnceForChunk already
owns that path. If the fetch itself went wrong — hotel wifi, an aborted
request, a service worker handing back a truncated entry — one retry fixes
it.

The retry cannot reuse the original specifier, since Vite rewrites it into a
hashed asset URL at build time; the only usable URL is the one the browser
names in the failure message, and a fresh query on it misses both the HTTP
cache and the Workbox precache.

This is also where a healthy load is acknowledged. Clearing the reload marker
used to sit in main.tsx right after render(), which runs before React commits
and long before any chunk is fetched, so the marker was gone before it was
ever needed.

* perf(client): load the page chunks on demand

Every one of the twenty pages was a static import, so the entry chunk carried
the planner, the journal, the atlas and the vacation planner for anyone who
only opened the dashboard. They now load through lazyWithRetry; the entry
chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349).

LoginPage stays static on purpose: anyone logged out lands there, and every
other route redirects there first.

The Suspense boundary sits above <Routes> rather than inside ProtectedRoute
so it stays mounted across navigations. react-router runs location updates
inside a transition, which keeps the current page on screen while the next
chunk arrives instead of flashing a spinner on every jump.

The precache is unchanged by design: workbox picks up the new chunks, so the
install size stays where it was (107 -> 178 entries, +31 KiB) and every route
remains available offline. This buys first paint, not install size.
2026-08-07 10:22:51 +02:00
Maurice 39b6d9f786 feat(client): catch render errors instead of blanking the page (#1805)
* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
2026-08-06 23:42:57 +02:00
Maurice 6c63a9bb60 fix(client): move to react-router 7.18.2 (#1804)
* refactor(client): opt into the v7 router behaviour while still on v6

Turning the behaviour on separately from the version bump keeps the two failure
modes apart: red here means a behaviour change, red on the bump means the import
rewrite. The suite is unchanged either way — 527 files, 11099 tests.

Only the two flags a non-data router understands are set; the rest belong to
createBrowserRouter, which this app doesn't use. Both are applied at all six
router mounts, so the tests exercise the same behaviour as production rather than
validating v6 while prod runs v7.

* fix(client): move to react-router 7.18.2

6.30.4 is affected by GHSA-wrjc-x8rr-h8h6 — open redirect through a backslash in
Link and useNavigate, which this app uses in 41 places — and the 6.x line has no
fix: react-router-dom 6.30.2 to 6.30.4 carries GHSA-jjmj-jmhj-qwj2 with no patched
version at all. Staying is not an option, and 7.0 to 7.17 only swaps it for
GHSA-chx6-hx7r-mcp5.

The package rename comes with it: react-router-dom is a re-export shim in v7, so
using it would only defer the same rewrite to the v8 jump. 77 occurrences across
60 files, all of them import specifiers and vi.mock strings.

The surface is small — useNavigate, useParams, useSearchParams, useLocation,
Routes, Route, Link, Navigate, useMatch, MemoryRouter, BrowserRouter — no data
router, no loaders or actions. Nothing else had to change; the future flags from
the previous commit are simply how v7 behaves, so they come back out.

npm audit still reports GHSA-qwww-vcr4-c8h2 on react-router (RSC mode CSRF, fixed
in 8.3.0). It needs RSC mode, which needs a server; this is a static SPA. Closing
it means the v8 major, which is its own piece of work.
2026-08-06 23:01:58 +02:00
Maurice e606ebd9b9 chore(server): pin the WebSocket envelope, dedupe the rate limiter (#1803)
* chore(nest): register LlmParseModule in AppModule

The module was only in the container because BookingImportModule and
PluginsModule happen to import it, so /api/admin/llm/local depended on those two
keeping that import. Register it where every other module is registered.

* refactor(nest): give the rate limiter one home and one instance

RateLimitService keeps its counters in a Map on the instance, but it was listed
in the providers of auth, oauth, transit and trip-invite, so each of them got its
own. Nothing is broken today — the bucket names in use (login/mfa/forgot/reset,
oauth_*, transit_*, trip_invite) don't overlap — but two modules picking the same
name would have quietly allowed twice the configured attempts.

Move it to nest/common, where it belongs now that four domains use it, and hand
it out through a small module they import. Not @Global: the e2e suites build a
container around one domain module, so a global AppModule never pulled in isn't
there at all.

transit.mcp.ts keeps its own instance on purpose — it keys by userId under
mcp_transit_* while the REST path keys by IP.

* test(ws): pin the wire envelope before touching the transport

Every frame on the socket is flat: {type, tripId, ...payload} out, {type, tripId}
in. Nest's WsAdapter dispatches on message.event and answers {event, data}, so
moving this onto a gateway without a custom serialiser would leave join silently
unhandled — no welcome.socketId to send back as X-Socket-Id, and every client
would then receive its own writes.

Nothing catches that today: the 100+ tests that touch realtime mock src/websocket
and never look at the bytes. WS-032 sends the exact shape the stock adapter would
and asserts the server ignores it.

* docs(nest): drop the parts of the README that no longer exist

strangler.ts, DEFAULT_NEST_PREFIXES/NEST_PREFIXES and tests/parity/ are all gone —
every request goes through Nest now — but the README still walks a new contributor
through adding a prefix and writing a parity test against the Express route.

Also spell out that the 80% gate is an average over src/nest/**, not a floor per
file, so a large untested module can sit behind the well-covered small ones.
2026-08-06 22:44:59 +02:00
Maurice 2b11990063 chore(client): take the GL engines out of the entry chunk (#1801)
* refactor(settings): load the GL map preview on demand

MapViewGL and JourneyMapGL are both behind lazy() so a Leaflet-only install
never downloads the GL engines. MapboxPreview imported mapbox-gl and maplibre-gl
statically, which put them back in the entry chunk and cancelled that out.

Wrap it in lazy()/Suspense on both the desktop tab and the mobile screen. The
entry chunk drops from 7987 kB to 5157 kB (2057 kB to 1292 kB gzipped); the
engines now ship as their own chunk, loaded when a GL provider is picked.

The preview no longer resolves synchronously, so the tests that read it right
after switching provider await it now.

* chore(client): drop unused deps, self-host MuseoModerno, add a bundle baseline

@react-pdf/renderer, react-window and @types/react-window have no importers
anywhere: TripPDF builds HTML for the browser print window and JourneyBookPDF
goes through marked. RegisterPage is dead too, /register renders LoginPage.

MuseoModerno was the last font still coming from the Google CDN through a
render-blocking <link>, while main.tsx already self-hosts Poppins and Geist for
exactly the reason stated in the comment there. Same weights, now bundled.

Add rollup-plugin-visualizer behind `npm run build:analyze` so the splitting work
has numbers to argue with, pin build.target instead of inheriting Vite's default,
and bring the workbox size ceiling down from 10 MB to 6 MB — anything above it is
dropped from the precache manifest silently, which only shows up as a broken
offline app.
2026-08-06 22:25:12 +02:00
Maurice 06f5bc2c86 fix(planner): keep the file-upload callback above the place bail-out (#1800)
PlaceInspector returns null when no place is selected, but the useCallback
for handleFileUpload sat below that return. Clearing the selection therefore
changed the hook count and React threw "Rendered fewer hooks than expected",
which takes the whole tree down since nothing catches it.

Move the callback above the bail-out and read the id through place?.id.
2026-08-06 21:59:30 +02:00
Konstantinos Thermos ab9ca4220a fix(atlas): translate unvisited country names via the locale resolver (#1798) 2026-08-06 16:14:02 +02:00
Maurice 143200cae6 chore(dashboard): remove the 4.0.0 release moment (#1792)
The FourZero show was built as a one-off for the 4.0.0 release and was
always meant to come back out afterwards. It lived in its own folder
plus two marked lines on the dashboard, so removing it is exactly that:
the folder and the two lines.

The beacon carried its own cut-off date of 23 August, so nothing about
this changes what users see between now and then beyond taking it out
early.
2026-08-05 19:59:16 +02:00
Konstantinos Thermos dd138e5293 fix(trips): default a new trip to the user's currency setting (#1790)
The new-trip forms hard-coded EUR and ignored settings.default_currency,
so creating a trip always opened on EUR even with USD set in
Settings > General > Currency. Read default_currency (falling back to
EUR when unset) in both the desktop TripFormModal and the mobile
MNewTripSheet; edit mode still pre-fills the trip's own currency.

Fixes #1784
2026-08-05 18:04:40 +02:00
dependabot[bot] c08530f928 chore(deps): bump undici from 7.28.0 to 7.29.0 (#1780)
Fixes CVE-2026-13697 (HIGH).
2026-08-05 15:34:28 +02:00
Renzo Beux 4b60cf0c0e Update the mobile sheet tests for the shared location helper
- reverse() now receives the UI language
- error assertions follow the translated journey.editor.* strings
  instead of the raw error.message and the generic common.error label
- a late error callback after a resolved one-shot fix is asserted to be
  a no-op, since a settled promise cannot reject anymore
- add tests for the new location button: reverse-geocoded fill and the
  translated inline denial message
2026-08-05 15:28:20 +02:00
Renzo Beux a66f70031c Wire the current-location button into the mobile entry sheet
- MJourneyEntrySheet renders the same crosshair button in its location
  field, with the coordinate fallback and reverse-geocode race guard
- Quick-capture auto-locate now goes through getCurrentPositionOnce(),
  replacing the raw navigator.geolocation call and the untranslated
  error.message it surfaced
- Error code to i18n key mapping extracted to geoOnceErrorKey(), shared
  by both editors
- Test for the race guard: a search result picked while the reverse
  geocode is in flight must survive
2026-08-05 15:28:20 +02:00
Renzo Beux 3ec4b33dbb Add "use my current location" button to Journey entry editor
- One-shot getCurrentPositionOnce() helper in useGeolocation with typed
  error codes (unsupported, insecure context, permission denied, timeout)
- Button inside the entry editor Location field fills coordinates from the
  device and reverse-geocodes the place name via /api/maps/reverse,
  falling back to formatted coordinates when no name is available
- New journey.editor.* i18n keys in all 23 locales
- Unit tests for the helper and component tests for the button flow
2026-08-05 15:28:20 +02:00
Renzo Beux 8a59c139c0 Sync the runtime SDK types with colours and note mobile tints only the badge 2026-08-05 15:28:15 +02:00
Renzo Beux f718240ffc Let a dayTintProvider send its own colour
Four tones cannot keep a twenty-stop trip's legs apart, which is most of
the point of colour-coding one. A contribution can now carry `#rrggbb`
alongside `tone`, per region or as a shorthand.

The split of control is: the plugin picks the hue, the host picks the
weight. A colour rides the same per-theme, per-region alphas as a tone,
and its OKLCH lightness is clamped into a per-theme band first, so no
contribution can hand a user a day card they can't read. The band
brackets the built-in tones rather than tightening around them, so an
ordinary colour passes through untouched and a hex equal to a tone
renders exactly like that tone. Hue and chroma are never touched - it
bounds a colour, it doesn't boost one, so a near-white grey still lands
as a faint tint.

Only `#rrggbb` survives normalization, and that is a security boundary
rather than tidying: the value ends up inside a `color-mix()` in an
inline `background`, a shorthand that takes a layer list, so a string
closing the paren early could append a `url(...)` and beacon everyone
viewing the trip.

A region gets exactly one paint instruction - what it names beats the
shorthand, a colour beats a tone at the same level - so the client never
has a tie to break. A region naming a colour the host can't use degrades
to `default` like a bogus tone already did.
2026-08-05 15:28:15 +02:00
Renzo Beux c89d2bf3a1 Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
2026-08-05 15:28:15 +02:00
Konstantinos Thermos 28ffc814c0 fix(llm): retry OpenAI import with max_completion_tokens
Newer OpenAI models (gpt-5.x) reject max_tokens with a 400 and demand
max_completion_tokens, so AI booking import failed and no reservations
were created. Send max_tokens first (understood by the classic chat API
and every local server), and only on a 400 naming max_completion_tokens
resend the whole request with that parameter instead — mirroring the
existing json_schema->json_object fallback beside it. Detection is by
API response, not a model-name allowlist.

Fixes #1760
2026-08-05 15:13:17 +02:00
Konstantinos Thermos 17dd6b7caa feat(map): toggle the trip map between default and satellite tiles
Add a corner button on the Leaflet trip map that switches the base layer
between the default street tiles and a tokenless satellite view (ESRI
World Imagery). The choice is a per-user setting (map_base_layer) that
persists and applies instantly, including offline. GL-renderer satellite
styles are unchanged.

Closes #993
2026-08-05 15:13:13 +02:00
Konstantinos Thermos 5315bacc2a fix(costs): save a picked payer when nobody splits the expense
The web expense modal only sent the payer when at least one split
participant was selected, so a personal expense (e.g. a flight paid by
one person, not shared) was stored with an empty payers[] and lost its
payment attribution. Drop the participants.size guard so a picked payer
always goes out, matching MCostSheet. "Nobody paid" stays expressible via
the explicit "No one paid yet" option (payerId 0); the #1286 test is
updated to use it.

Fixes #1766
2026-08-05 15:11:44 +02:00
Konstantinos Thermos 7da4cad086 fix(admin): stop a stale base URL from hijacking the Anthropic endpoint (#1777)
The AI Parsing admin panel kept the Base URL in state after switching provider
and saved it unconditionally, so moving a configured Local/OpenAI provider to
Anthropic left the old host in the stored config and misrouted every Anthropic
request away from api.anthropic.com. Clear baseUrl on save when the provider is
Anthropic, matching the per-user LlmConnectionSection behaviour.

Fixes #1748
2026-08-04 13:18:12 +02:00
Maurice 446bbdc38f fix(client): stop shared items counting towards the recipient bag weight
An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
2026-08-03 21:59:34 +02:00
Maurice d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +02:00
Maurice e25a1276dc feat(client): let people actually type a bag weight limit
The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
2026-08-03 21:40:02 +02:00
Maurice 628f4e78cd fix(server): let a copied packing item keep its weight
createItem never accepted weight_grams or bag_id — not in its signature, not
in the INSERT — so cloneItem had nothing to pass on and every copy arrived
empty. For a group trip that meant re-entering every weight by hand for each
traveller, which is what people were doing.

The bag needs more care than the weight. Bags belong to a trip, but they can
be assigned to a person, so inheriting the original bag would drop your copy
into somebody else luggage and inflate their total. A copy keeps the bag only
when nobody owns it or when the copier is one of its members.
2026-08-03 21:40:02 +02:00
Maurice 4e090411fe fix(server): stop a failed notification import from crashing the run
The fire-and-forget notification sends guard the send() call but not the
dynamic import around it, so a rejected import escapes as an unhandled
rejection. In CI that surfaced as an EnvironmentTeardownError from vacay
after the suite had already passed, failing the run with every test green.

trips, packing and collections already catch on the import chain; collab and
the two vacay sends were missing it.
2026-08-03 20:21:18 +02:00
Maurice 8ab50dcbbb docs(wiki): explain visited versus planned countries in the atlas
The page still described the old behaviour, where every trip counted as a
visit regardless of its dates.
2026-08-03 20:21:18 +02:00
Maurice 6019b3ed4e feat(client): carry the planned/visited split into the mobile atlas
The mobile screen shares useAtlas, so it inherits the switch state; it gets
its own compact toggle next to the bucket-list button. Search now labels a
planned country as such instead of calling it visited, the stats card hangs
the planned count off the country total, and the suggestion list keeps
places you have actually been at the top.
2026-08-03 20:21:18 +02:00
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice d9778ed7c0 fix(server): keep the passport card in step with the atlas
getTravelStats derives its countries independently of the atlas service, so
without the same date filter the dashboard would have kept counting trips
the map no longer treats as visited — the drift #1490 closed.

Manually marked countries and the hidden-country tombstones are untouched.
2026-08-03 20:21:18 +02:00
Maurice 269bd9140f feat(server): count only started trips as visited in the atlas
Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl aec2d8cd87 docs: regenerate migration-graph after the notifications fold
Import-scan regeneration: notificationService and the folded
inAppNotifications leave the graph, the notifications cluster is consumed as
plain infra by the DI service, and the unblock cascade over-delivered —
repointing memories' sends erased that cluster's last domain edge, so BOTH
adminService and journeyService join the ready frontier (adminService heads
the order). Adds the dated correction bullet and the 'Quirks fixed after the
notifications fold' section (fixed vs deliberately-preserved).
2026-08-02 11:36:46 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl d203d8cccd chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store
(services/inAppNotifications.ts) fold together into the DI-native
NotificationsService over DatabaseService + RealtimeService; the prefs
matrix, transports, channel registry and inAppNotificationActions stay plain
infra modules. A 1-export notifications.bridge covers scheduler, legacy
adminService/memories and the six lazy fire-and-forget sends; AdminController
and the plugin RPC host (24th factory dep) inject. The 5-tool registrar +
notifications-in-app resource move to notifications.mcp.ts. Tests move with
case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites
repoint path mocks/warm-ups; the module e2e goes DI-native.

Also carries the verified-quirk fixes that would normally trail as a
separate fix(server) commit (file-overlap with the fold): toUtcIso on all
three created_at paths (INOTIF-013/014), respond claims the response CAS
before running the action handler with release-on-failure (INOTIF-011/012),
synology's fire-and-forget send gains .catch, the dispatch-failure log
unwraps Error messages (NSVC-021), and the MCP resource description drops
its false 'unread first' claim.
2026-08-02 11:36:06 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 74be789f22 docs: regenerate migration-graph after the passkey fold 2026-08-02 10:20:38 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00
jubnl e65f851446 chore: move passkey backend to proper DI 2026-08-02 10:10:33 +02:00
jubnl f40c66da6b fix(server): tidy oidc quirks after the DI fold 2026-08-01 18:36:07 +02:00
jubnl 5da98d0fb9 docs: regenerate migration-graph after the oidc fold 2026-08-01 18:25:51 +02:00
jubnl ec0709741a chore: move oidc backend to proper DI 2026-08-01 18:25:28 +02:00
jubnl d892b61a59 fix(server): tidy auth quirks after the DI fold
Verified defects the relocation carried byte-for-byte, each pinned by a
regression test (AUTH-DB-089..093):

- getTravelStats kept dropping lat/lng of exactly 0 (equator / prime
  meridian) through its falsy check — now explicit != null.
- registerUser's INSERT user -> invite used_count bump -> joinTripAsMember
  ran statement-by-statement; the whole signup now runs in
  db.transaction() so a mid-sequence throw rolls everything back.
- verifyMfaLogin's backup-code burn and last_login/login_count bump now
  commit as one atomic pair.
- deleteMcpToken's revokeUserSessions call is best-effort (try/catch),
  matching the changePassword/resetPassword revocations.
- updateApiKeys' current! non-null assertions became current?. ?? null —
  a user row deleted mid-request degrades to a 0-row UPDATE, not a
  TypeError/500.

Deliberately preserved (documented in migration-graph.md): the two
divergent "Password authentication is disabled" strings and enableMfa's
15-min pending-secret TTL after a wrong code.
2026-08-01 17:46:54 +02:00
jubnl 9bc31e6a1c docs: regenerate migration-graph after the auth fold
Re-ran the three-pattern import scan over server/src: authService row
deleted, thirteen helper rows updated (nine lose their last legacy
importer), oidcService/passkeyService join the ready frontier as
auth.bridge repoints, adminService is blocked on notificationService
alone, atlas.bridge's death recorded, dependency-honest order step 5
ticked, a post-auth-fold corrections entry added (bridge tax held exactly;
the reverse cycle-break, the in-container module fan-out and path-mock rot
are now tracked shapes), and the preserved auth quirks listed as trailing
fix(server) candidates.
2026-08-01 17:40:25 +02:00
jubnl fb87202683 feat(server): validate auth request bodies
DTO ratchet for the auth domain (collections sibling-commit precedent):

- shared/src/auth/auth.schema.ts gains the six missing request schemas —
  mapsKey (nullable: the client clears with an explicit null), apiKeys and
  settings (per-key partials, nullable key values), appSettings (open
  record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
  lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
  (the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
  params typed (9 AuthController + 5 AuthPublicController), validated by
  the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
  (PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
  envelope on a shapeless login body; auth.controller.ts drops its
  duplicated avatarDir const in favour of the auth.helpers export.
2026-08-01 17:40:16 +02:00
jubnl 45564b0a8a chore: move auth backend to proper DI
Fold the 1497-line services/authService.ts into the DI-native nest/auth
domain (Wave-5 chain opener, unblocked by the atlas fold):

- Pure crypto half (backup-code hash/match/generate, stripUserForClient,
  key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and
  avatarDir mkdir — documented parity exceptions) moves verbatim to the
  plain module nest/auth/auth.helpers.ts.
- DB half folds into AuthService over DatabaseService + injected
  PermissionsService (ex permissions.bridge) + AtlasService (ex
  atlas.bridge, deleted on schedule). mfaSetupPending and the per-email
  reset throttle stay module-scoped so the bridge and container instances
  share them; avatarDir and require('../../../package.json') re-anchor one
  directory deeper — with the two injection swaps, the only non-verbatim
  lines. Controller-facing method surface unchanged.
- New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts
  token verification, the journey/notifications/transports registrars'
  isDemoUser, and the still-legacy adminService/oidcService/passkeyService.
- 15 domain *.mcp.ts demo guards now inject AuthService (their modules
  import AuthModule); atlas.mcp.ts alone stays on the bridge because
  AuthService injects AtlasService and the reverse module edge would close
  an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and
  PasskeyEnabledGuard inject AuthService too.
- Tests move with case IDs preserved: authService.test.ts ->
  auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts
  (+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the
  previously untested getAppConfig/login/MFA/reset/admin-settings branches
  now under the src/nest coverage gate). auth.e2e converts DI-native (real
  bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an
  instance spy; websocket/integration suites repoint to bridge/helpers.

Quirks preserved byte-for-byte (0-coord drop in getTravelStats,
un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded
revokeUserSessions in deleteMcpToken, current! assertions in
updateApiKeys) — fixes land separately.
2026-08-01 17:39:59 +02:00
jubnl 464fe5ff77 docs: regenerate migration-graph after the atlas fold 2026-08-01 15:50:46 +02:00
jubnl ae3ba18b9e chore: move atlas backend to proper DI
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService:
the stats aggregation, visited countries/regions with the #1490 tombstone and
cascade logic, and the bucket-list CRUD become methods over the injected
DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1
boundary stores with their #1576 streaming builders, the point-in-polygon
indexes, Nominatim geocoding with its shared throttle, and every module-scoped
cache including the import-time unref'd cleanup interval — moves verbatim to
the plain module atlas-geo.ts so the caches stay process-global across the
container instance, the bridge instance and test helpers (assetPath
re-anchored one directory deeper, the only non-verbatim line).

The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in
mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when:
atlas-addon gate is parity here (legacy tools and resources both gated; the
REST controller deliberately does not). resources.test.ts retires with its
last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC
host swaps its 9 atlas imports for the injected AtlasService (23rd constructor
dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer,
authService.getTravelStats, and dies when authService migrates.

Adopts atlas.dto.ts over the existing shared schemas and clears all three
AtlasController entries from body-contract-allow-list.ts: the hand-rolled
'name and country_code are required' 400 becomes the ZodValidationPipe
envelope (todo/places trade) while the whitespace-only bucket name keeps its
legacy 'Name is required' trim guard.

Fixes the verified defects the relocation had faithfully carried, each with a
regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four
multi-statement mark/unmark writes now run in db.transaction() (the region
cascade nests as a savepoint), the trip-less countryPlaces early return
honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and
empty-string notes on bucket update become '?? null', the mutating bucket SQL
is user-scoped, and the MCP region/country-places tools uppercase their codes
to match REST.

Quirks deliberately preserved: the detached GET-path place_regions backfill
writes, getStats' two divergent return shapes, the import-time cache-cleanup
interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the
whitespace-only-name silent no-op on bucket update.

Tests move with their case IDs: atlasService.test.ts becomes
unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls
through the constructed service, plus bridge-delegation cases), the atlas e2e
converts to the DI-native pattern (real SQL against the full temp-db schema),
and the plugin-host path mock becomes a constructor stub.
2026-08-01 15:50:36 +02:00
jubnl c78134831f fix(server): tidy collections quirks after the DI fold 2026-08-01 14:03:05 +02:00
jubnl 79c20c07c8 docs: regenerate migration-graph after the collections fold 2026-08-01 13:54:36 +02:00
jubnl d11b7bb054 feat(server): validate collections reorder and delete-many bodies 2026-08-01 13:54:36 +02:00
jubnl cab49ea511 chore: move collections backend to proper DI 2026-08-01 13:54:19 +02:00
jubnl 4f8300e2ba fix(client): fall back to scheduled stop times in transit reservations 2026-08-01 12:49:22 +02:00
jubnl 2b03f9a024 fix(server): tidy transit itinerary quirks after the DI relocation 2026-08-01 12:49:22 +02:00
jubnl e80ae79b77 chore: move transitItineraryService into nest/transit as pure helpers 2026-08-01 12:45:35 +02:00
jubnl 57cc676e8a test(uploads): pin the uploads subdir list across Dockerfile and boot
The set of uploads subdirectories is hand-mirrored between the image build
and the boot-time mkdir in server/src/index.ts, and drift is invisible
until a user whose bind-mounted uploads dir is not writable by node hits an
EACCES on the first upload to the missing dir (#1762).

Parse both lists and assert they match, with a guard case so the regexes
cannot silently match nothing and pass vacuously.
2026-07-31 15:41:02 +02:00
jubnl 1fb6d19f6f fix(uploads): pre-create journey and places upload dirs
Both were created lazily on the first upload, which needs write permission
on the uploads mount point itself at request time. On hosts whose
bind-mounted uploads dir is not writable by the runtime user, every feature
writing into an already-existing subdir keeps working while journey photo
and place image uploads fail with EACCES (#1762). Creating them in the
image and at boot turns that into one loud startup failure instead of a
stray 500 on upload.

Also drop the recursive chown over all of /app from the final layer: it
copied up every inode it touched and duplicated node_modules, dist and the
client bundle into a 518 MB layer. The chown now rides the layer that
installs node_modules, later copies carry --chown=node:node, and the last
layer chowns only the paths it creates. Everything under /app stays
node-owned; image size 2.36 GB -> 1.68 GB.
2026-07-31 15:40:24 +02:00
jubnl f02f255016 fix(places): cap the Naver list-import response like the Google one
The place fold capped the Google list-import body at 8 MB but left the Naver
importer reading its pages unbounded — same attacker-influenced URL (the folder
id), and worse in practice because that path is a pager that buffers a fresh
body on every iteration.

fetchPage now checks the declared content-length before reading and the actual
length after, returning the existing 'Failed to fetch list from Naver Maps' 502
on either. Reading the body as text and parsing it explicitly (rather than
apiRes.json()) is what makes the post-read check possible; the malformed-payload
branch keeps its 'Invalid list data received from Naver Maps' 400 because the
JSON.parse stays inside the same try.

The post-read check is the one that matters for a hostile server: a chunked
response carries no content-length, so the declared check alone is evadable.
Covered for both providers now — PLACE-SVC-073b (Google, chunked) and
PLACE-SVC-074..077 (Naver: declared, chunked, malformed, happy path).

The five Naver fetch mocks in the integration suite move from json() to text().
2026-07-30 17:53:48 +02:00
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00
xthephreakx 57017b8aed fix(navbar): prevent centred tab pill overlapping actions on tablet (#1747)
The desktop navbar's centred tab pill is absolutely positioned, so on the
md-range (tablet, ~768-1024px) its right edge slid underneath the right-hand
action cluster (notification bell + user menu) once the tab text labels made it
wide enough.

Hide the tab text labels below the lg breakpoint (icon-only on tablet, icon +
label on desktop ≥1024px), which keeps the pill narrow enough to clear the
actions. The icons gain title/aria-label so they stay accessible without text.

Co-authored-by: phreaky <phreakydev@gmail.com>
2026-07-29 22:09:40 +02:00
jubnl cd67074e0b Merge remote-tracking branch 'origin/dev' into dev 2026-07-29 21:40:35 +02:00
jubnl 4e0ac74fcb chore(deps): bump @modelcontextprotocol/sdk to ^1.30.0
Security fixes in the MCP SDK.
2026-07-29 21:40:05 +02:00
jubnl c7760cb823 docs: regenerate the legacy-services dependency graph after the transit fold 2026-07-29 21:40:05 +02:00
jubnl f4bf7cf3dc chore: move transit backend to proper DI
Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
2026-07-29 21:39:51 +02:00
Maurice 129f952db4 fix(pdf): stop a flowing day stranding its header at the foot of a page
Keeping a stay whole moved its cards to the next sheet, and the header bar
stayed behind on the old one — empty, above a stretch of white, with the
same day printed again overhead. The repeat itself is the continuation
marker from #1471; what was wrong is that there was nothing left to
continue from.

break-after on the thead and break-before on the tbody are both ignored by
Chromium here, measured rather than assumed. Holding the whole day together
is what works: a day that fits moves as one, and a day too long for a page
still breaks and still repeats its header. Scoped to the flowing layout,
since a day that starts its own page can never strand one.
2026-07-29 21:32:39 +02:00
Maurice eb817dea65 fix(pdf): keep a stay whole across a page edge
An accommodation could be cut in half by a page break — the check-in time
at the foot of one sheet, the hotel name and address at the head of the
next, under a repeated day header that read like a duplicate day. Place and
note cards have carried break-inside: avoid all along; this one never did.

It could always happen to a day that overflowed its page. Letting the days
flow means content meets a page edge far more often, which is how it turned
up. Printing the same trip through Chromium: two torn stays before, none
after.
2026-07-29 21:32:39 +02:00
Maurice 5dbb2db3ee fix(planner): keep the PDF tooltip inside the window
The export button is the one toolbar button with a hand-rolled tooltip
instead of the shared one. It was anchored with `right: 0` and never
wrapped, so its label grew leftwards out of the day plan — the leftmost
pane — and off the window.

The shared tooltip is portalled and clamped to the viewport, which is what
every neighbouring button already uses. The hover state it needed was
threaded down from DayPlanSidebar and has no other reader, so it goes too.
2026-07-29 21:32:39 +02:00
Maurice be5b48532d fix(pdf): dress the layout switch like the rest of the app
The preview shipped a bare checkbox, which is not what a toggle looks like
anywhere else in TREK. The overlay is imperative DOM, so the switch from
Settings/ToggleSwitch cannot be rendered into it — as static markup it would
carry no behaviour — but its geometry and its tokens can be, and the control
now reads as the same one.
2026-07-29 21:32:39 +02:00
Maurice 03be438e0b feat(pdf): make the page break between days optional
Every day started a new page, so a plan of short days printed one sheet per
handful of lines and looked nothing like the view it was exported from.

The preview now carries the choice, next to Save as PDF, and remembers it.
The two layouts differ by a single class on <body>, so ticking the box
re-lays the open preview out immediately instead of rebuilding the document
and re-fetching every photo.

Breaking per day stays the default — this only adds the other option.
2026-07-29 21:32:39 +02:00
Maurice dc4ccb2304 chore(i18n): add the PDF page break label 2026-07-29 21:32:39 +02:00
jubnl a7cb4eb880 chore: point MCP.md to the wiki 2026-07-29 21:24:57 +02:00
jubnl 09f7cc4811 fix(maps): repair the quirks preserved by the DI fold 2026-07-29 20:27:30 +02:00
jubnl 74e1e444c5 docs: regenerate the legacy-services dependency graph after the maps fold 2026-07-29 20:21:10 +02:00
jubnl 4706112516 feat(server): adopt zod DTOs for the maps body contracts 2026-07-29 20:21:09 +02:00
jubnl 2e40c19249 chore: move maps backend to proper DI 2026-07-29 20:20:57 +02:00
Maurice 397f504d01 fix(planner): reach the day reorder popup with a finger too
The popup is a modal, so it portals out of the sidebar and misses the
opt-in its rows sit inside everywhere else. Its rows already carried a bare
draggable attribute, which on a tablet meant a long press selected the row
label and nothing moved.
2026-07-29 19:15:57 +02:00
Maurice edea67ff7e fix(planner): let tablets drag places into the day plan (#1616)
Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
2026-07-29 19:15:57 +02:00
Maurice e7ccbcdf86 feat(planner): add a long-press touch drag bridge
A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
2026-07-29 19:15:57 +02:00
Maurice 98ad2450a2 feat(db): make the SQLite journal mode configurable (#1675)
WAL was hardcoded, and on network-backed storage — Azure App Service Linux,
SMB/NFS volumes on NAS and PaaS — SQLite documents it as unsafe, because the
coordination runs through the -shm file and mmap. Operators had no way out.

TREK_DB_JOURNAL_MODE and TREK_DB_SYNCHRONOUS now set both pragmas, defaulting
to today's behaviour. The mode lives in the file header, so all three
processes that open travel.db read the same configuration — the server,
reset-admin.js and migrate-encryption.ts, which used to hard-set WAL and would
have silently reverted a deliberate DELETE setup on the first key rotation.

An invalid value falls back and warns rather than refusing to start, and the
mode that actually took effect is read back and logged, so an operator can see
whether the setting landed.
2026-07-29 17:02:56 +02:00
Maurice e62dceb4bb fix(backup): snapshot the database instead of archiving it live (#1675)
The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
2026-07-29 17:02:56 +02:00
Maurice 14bc41f2cf chore(i18n): drop the unused map template placeholder key
Left behind by the tile URL change (#1733): all four call sites use the
.select variant, the plain key had no consumer.
2026-07-29 15:14:21 +02:00
Maurice f35c3f31e2 fix(transit): prefer displayName for the line identifier (#1715)
Transitous exposes routeShortName, which many operators — German long
distance among them — do not fill. displayName carries the identifier
passengers actually see, with routeShortName as the fallback.
2026-07-29 15:14:21 +02:00
Maurice 4f99834965 test(admin): stop the plugin sheet test racing its own Escape
MSheet binds the Escape listener in an effect, so a key fired the moment the
sheet content appears can land before the listener exists. Under a loaded
full-suite run that made the test fail on its own timing. It retries until
the sheet is gone rather than pressing once.
2026-07-29 15:14:21 +02:00
Maurice 8b7e2ddaa5 fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned
from Singapore showed the wrong state. It evaluates in the timezone of the
place now, from Google's structured opening periods rather than the localised
description text — parsing display strings breaks as soon as the account
language is not English. Midnight-spanning hours, closed days and 24/7 are
covered.
2026-07-29 15:14:21 +02:00
Maurice 724b52aa92 fix(plugins): broadcast itinerary.unassign to open sessions (#1705)
assign broadcast, unassign did not, so the place stayed on the Plan tab until
a manual refresh. It emits the same event as the REST route now. createPlace,
updatePlace and deletePlace in the same file had the identical gap.
2026-07-29 15:14:21 +02:00
Maurice aaef3e3e76 fix(reservations): honour the 24h time setting (#1725)
The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
2026-07-29 15:14:21 +02:00
Maurice 9ab9665e99 fix(llm-parse): decode .eml as MIME before extracting text (#1724)
Attachments were treated as ready-made HTML, but a real mail is multipart
with base64 or quoted-printable transfer encoding. What reached the model was
the plaintext headers followed by a wall of base64, truncated before any
content — which is why a sender name from the Subject line still showed up
while the booking never did.

The html part is preferred, plain text is the fallback, transfer encoding and
charset are decoded and entities resolved before the text goes out. No new
dependency. If no text part is found the previous raw path still runs.
2026-07-29 15:14:21 +02:00
Maurice 50600b74cd fix(maps): stop the photo endpoint answering 404 for photo-less places (#1727)
Opening one trip fires a 404 per place without a photo, 30 to 60 of them from
one IP in seconds. CrowdSec and fail2ban read that as scanning; the reporter
had three users banned within five minutes. A place having no photo is not an
error and no longer answers as one.

OSM places were also sent to Google as node:/way:/relation: ids, which can
only return 400 INVALID_ARGUMENT — a billed round-trip and about 1.3s per
place. getPlacePhoto already guarded coords: this way; the OSM prefixes now
get the same treatment, as does getPlaceDetailsExpanded, which had no guard.

The negative cache separates a place having no photo from a failed provider
call instead of expiring both after five minutes and replaying the storm.
2026-07-29 15:14:21 +02:00
Maurice 9ecca404c5 fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its
DNS record. The map never asked for d — Leaflet defaults to abc — but the
offline prefetcher rotated over a to d, which is where the failing requests
came from, and it cached most tiles under a host the map never requests.

Presets and the prefetcher use the apex host now. A URL already saved in
user_settings is rewritten where it is read and written back, so existing
instances heal without a migration touching what someone typed themselves.

The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host
never matches the apex domain, so the prefetch fetch would have been blocked.
The service worker rule had the same gap and would have stopped filling the
tile cache.
2026-07-29 15:14:21 +02:00
Maurice f013c4964c chore(client): set every coverage threshold to 85
Treats the gate as a floor rather than a target — the suite runs well above it,
and a PR that legitimately trades a few points should not have to move the
numbers to land.
2026-07-29 12:56:17 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice a4bf815d24 test(import): read the upload body instead of parsing it as FormData
These are the only two test files that call request.formData() inside an MSW
handler, and they hold exactly the three tests that failed on CI while passing
locally. The body axios' XHR adapter produces under jsdom is one Node 24's
multipart parser rejects, so the handler threw before reading a field and the
component reported a failed upload. Reading the raw payload asserts the same
thing without depending on the parser.
2026-07-29 11:55:31 +02:00
Maurice 1d9fe9a3df test(import): stop the upload fixtures lying about their size
The helpers built a one-byte File and then redefined size to 100, so the
multipart body and the length it announced disagreed. Node 24 rejects that
while 22 lets it through, which is why only CI saw it: every test that
uploaded such a file and expected success failed, the one expecting an error
passed for the wrong reason, and the KML case with a truthful File was fine.
2026-07-29 11:55:31 +02:00
Maurice be0164eb91 ci: run the client gates alongside the suite instead of in front of it
The client suite is ~11 minutes now. With typecheck and lint sitting in front
of it in the same job, a single lint finding threw away the whole test signal
for that run — twice today. Both jobs pay the install and the shared build,
which is cheap next to running the two in series.
2026-07-29 11:55:31 +02:00
Maurice 16922766e5 test: give async assertions a realistic budget on CI
waitFor and findBy* default to 1s. That holds on a dev machine but not on a
4-core runner executing 527 test files in parallel forks, where three import
dialog tests timed out waiting for their upload to settle.
2026-07-29 11:55:31 +02:00
Maurice 99816bff4c test(costs): escape the BOM in the CSV export assertion
A literal U+FEFF inside a regex trips no-irregular-whitespace, which is an
error rather than a warning, so the client lint job failed. The escape is
equivalent.
2026-07-29 11:55:31 +02:00
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00
Maurice b80bd3992b test: pick up the rest 2026-07-29 11:55:31 +02:00
Maurice 1e66bcc701 test(pages): cover the page hooks, stores and the api client 2026-07-29 11:55:31 +02:00
Maurice bbb0f93af2 test(components): cover map, collections, settings, journey and admin 2026-07-29 11:55:31 +02:00
Maurice a3b263017f test(planner): cover the day plan sidebar and the planner dialogs 2026-07-29 11:55:31 +02:00
Maurice 12558fe9c4 test(mobile): cover admin, settings and the remaining screens 2026-07-29 11:55:31 +02:00
Maurice c071901e55 test(4-0): cover the KEINE ANGST show 2026-07-29 11:55:31 +02:00
Maurice c884ddf527 test(mobile): cover the trip screens 2026-07-29 11:55:31 +02:00
jubnl 287dceb532 chore: wire TripsService into the plugin RPC host and retire the legacy tripService
Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md.
2026-07-29 01:53:54 +02:00
jubnl 530c7cb005 chore: move trip backend to proper DI
Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
2026-07-29 01:53:40 +02:00
jubnl a3c2cfa4b2 fix(shared): tighten WS payload schemas after the MCP drift fixes
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
2026-07-29 00:34:58 +02:00
jubnl 23486f841f fix(server): align MCP assignment/day WS payloads with the REST shapes
move_assignment omitted newDayId (moved assignments vanished from
collaborator views), reorder_day_assignments sent assignmentIds where
the client reads orderedIds (reorders emptied the day remotely), and
delete_day sent { id } where the client reads dayId (days never
disappeared). Registry drift findings from the WS contract work.
2026-07-29 00:34:53 +02:00
jubnl a8a5f7e611 chore: update sonar props 2026-07-29 00:24:26 +02:00
jubnl a2011dc991 feat(client): handle WS events against the shared registry
remoteEventHandler's two switches become enumerable lookups keyed by
TrekWsTripEventName (bodies unchanged); WebSocketEvent.type derives
from the registry. wsEventPolicy.ts makes the client's non-reactions
explicit (HANDLED_OUTSIDE_TRIP_STORE + IGNORED_WS_EVENTS) and the
registry-parity test turns an unclassified server event into a red
test instead of silence. Zero behavior change.
2026-07-29 00:17:47 +02:00
jubnl f66f8d8bd9 feat(server): type RealtimeService against the event registry
broadcast/broadcastToUser gain registry-typed overloads (event name
must be a TREK_WS_EVENTS key, payload must match its schema) with the
plugin: namespace as the deliberate escape hatch; the 13 service
wrappers forward the generic signature. Compile-time only — the
runtime pass-through and all call sites are unchanged.
2026-07-29 00:16:08 +02:00
jubnl c0baaeb898 feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
2026-07-29 00:15:56 +02:00
jubnl a2861d40f3 feat(server): injectable RealtimeService over the ws singleton
Nest services inject RealtimeService (call-time delegation to the
websocket module, DatabaseService pattern) instead of importing
broadcast/broadcastToUser globals. Zero behavior change; test edits
are constructor-arg additions plus RealtimeModule in e2e harness
imports only.
2026-07-29 00:07:51 +02:00
jubnl 80f3958569 fix(server): silence expected-error noise in test stderr 2026-07-28 22:31:46 +02:00
jubnl c7dc38a5b9 fix(server): close the notification-send teardown race in tests 2026-07-28 22:31:40 +02:00
jubnl 65fb00d63b feat(server): type MCP access groups against SCOPES + boot gate 2026-07-28 20:56:43 +02:00
jubnl 59ea420f1d feat(nest-mcp): host-typed access groups + entry validation hook 2026-07-28 20:56:29 +02:00
jubnl ee2ea1d065 refactor(server): extract addon enablement into the addons domain 2026-07-28 20:22:01 +02:00
jubnl cafff003a3 refactor(server): move instance-URL resolution to app-config 2026-07-28 20:21:09 +02:00
Maurice 292f1b181c Document track colours in the wiki
Map-Features had no GPX section at all, so the tracks paragraph is new
rather than an addition.
2026-07-28 07:25:36 +02:00
Maurice 2b8b55aa8f Translate the track colour strings 2026-07-28 07:25:36 +02:00
Maurice 502f604ca2 Reach the track colour from the mobile sheet
MPlaceSheet had no relation to route_geometry at all, so the section is
new. It saves through the single-field patch the sheet already uses for
the image, which keeps it clear of the edit form and its prefill.

The browse list gets the same colour stroke as the desktop sidebar. It
matters more here: the mobile map is full-bleed with no sidebar next to
it, so without the stroke a coloured line has nothing to tie it to.
2026-07-28 07:25:36 +02:00
Maurice 3c575a06f4 Add the track colour picker to the place inspector
New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
2026-07-28 07:25:36 +02:00
Maurice fcabb4c645 Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature
property filled. Leaflet needed more work: the GPX polyline passed its
style as bare color/weight props, and react-leaflet only calls setStyle
when the pathOptions reference changes — a recoloured track would have
kept its mount-time colour until the next reload. Every other vector
layer in here already uses pathOptions.

A track with a colour gets a white casing so it survives satellite
imagery and dark basemaps. The casings live in their own pane below the
lines: Leaflet stacks paths in insertion order, so drawn inline they end
up over the line of a track that was added earlier. They are always
mounted and hidden by opacity, otherwise giving a track a colour remounts
the path into the wrong position.

Tracks are clickable now — 3.5px is not a target, so the hit area is a
transparent 14px line on top. bubblingMouseEvents is off on it: paths
bubble to the map by default (markers do not), and the map's own click
handler would clear the selection the track just made. The GL side needs
the mirror image of that, a guard so a click on a cluster bubble over a
track still belongs to the cluster.
2026-07-28 07:25:36 +02:00
Maurice 1b5317a817 Give GPX tracks their own colour (#776)
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.

Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.

The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.

Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
2026-07-28 07:25:36 +02:00
jubnl 351b5fb4ec fix(server): fix the quirks preserved by the budget DI migration 2026-07-28 00:04:50 +02:00
jubnl 40f3103c04 docs: tick budgetService in migrate.md and refresh the migration graph 2026-07-27 23:48:23 +02:00
jubnl cf1b9e4c06 feat(server): validate budget request bodies with shared Zod DTOs 2026-07-27 23:48:23 +02:00
jubnl 7f34dadf70 chore: move budget backend to proper DI 2026-07-27 23:48:05 +02:00
jubnl b0041f34ba fix(server): fix the quirks preserved by the exchange-rates DI migration
- the Frankfurter fetch now carries an AbortSignal timeout (10s) and a 1 MB
  response-size cap, and the body is boundary-validated through a type guard
  instead of an as-cast
- fetch failures are logged instead of silently swallowed (callers still get
  the graceful null)
- the inflight-coalescing cleanup moved into .finally so a rejected fetch
  can no longer leak the key
- the dead convertWithRates export (zero call sites) is dropped from the
  service and the bridge

Kept on purpose: the beyond-TTL stale-cache fallback, the >1-keys
empty-response heuristic and the || falsy-coercion defaults.
2026-07-27 23:03:59 +02:00
jubnl 166fe798a7 docs: tick the exchangeRateService fold in migrate.md and refresh the migration graph 2026-07-27 22:58:14 +02:00
jubnl a3e6eb52a4 chore: fold exchangeRateService into the nest budget domain 2026-07-27 22:58:09 +02:00
jubnl 132eb2035f fix(server): fix the quirks preserved by the permissions and auditLog DI migrations
Permissions: stored levels are validated at load (corrupt/out-of-range rows
fall back to the default consistently across all readers), the load-error
swallow is narrowed to missing-table (anything else logs), and an
all-skipped save no longer opens a transaction or flushes the cache.
The module-scoped cache stays by design (shared DI+bridge invalidation),
and the checkPermission 'admin' case stays — it is reachable (correct deny
for non-admins on admin-level actions), not dead code.

Audit: data/logs creation is lazy (no import-time disk side effect; the
LOG_LEVEL freeze remains — tests/setup.ts timing contract), the log helpers
gate on a real severity threshold (error < warn < info < debug; prod
default 'info' unchanged), file-IO failures leave a console.error trace
instead of vanishing in bare catches, and resolveUserEmail treats only
null/undefined as anonymous so a real id 0 resolves via the DB.
2026-07-27 22:34:41 +02:00
jubnl 2944cd32a1 docs: tick permissions and auditLog in migrate.md and refresh the migration graph 2026-07-27 22:09:08 +02:00
jubnl 6ff936c613 chore: move auditLog backend to proper DI 2026-07-27 22:09:08 +02:00
jubnl 1d56de3897 chore: move permissions backend to proper DI 2026-07-27 22:08:37 +02:00
jubnl 55cb2148d4 fix(server): fix the quirks preserved by the day DI migration 2026-07-27 20:58:03 +02:00
jubnl c276e3a79a docs: tick dayService in migrate.md and refresh the migration graph 2026-07-27 20:57:18 +02:00
jubnl 2d1eec76c3 chore: move day backend to proper DI 2026-07-27 20:57:01 +02:00
jubnl e62628bf93 feat(server): adopt zod DTOs for the day and accommodation body contracts 2026-07-27 20:56:45 +02:00
jubnl d952eaf4d4 fix(server): fix the quirks preserved by the reservations DI migration
Two verified defects carried through the parity fold:

- The create-path accommodation metadata sync gated on the raw
  accommodation_id instead of the resolved one, so a hotel whose
  accommodation was just auto-created never received its metadata
  check-in/out times or confirmation. Now keyed off resolvedAccommodationId
  (RESV-SVC-006 re-pins the fixed behavior).
- The multi-statement writes ran outside transactions: create (accommodation
  insert + reservation insert + endpoint save + metadata sync), update
  (accommodation upsert + update + endpoint replace + sync), remove (the
  3-delete cascade), setReservationTravelers (delete + inserts) and
  resyncReservationDays now run in db.transaction() — a mid-write failure no
  longer leaves partial state (RESV-FIX-001/002 pin the rollbacks).

Left as-is on purpose: the truthy updatePositions dayId check, the
empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id
normalization, the optional day_plan_position wire tolerance and the
swallowed notifyBookingChange catches — all contract or intentional
behavior, not defects.
2026-07-27 19:52:00 +02:00
jubnl c7dde2af2e docs: tick reservationService in migrate.md and refresh the migration graph
- nest/README.md: reservations joins the DI-native list; recipe history gains
  the residue-fold paragraph; next up is dayService per the dependency-honest
  order.
- migrate.md: wave-4 entry ticked, with the migration-graph correction borne
  out — reservationService imported neither budgetService nor dayService.
- migration-graph.md: regenerated for the post-fold import graph (row/node
  removed, frontier and order updated, dayService is the next pick).
- plugins/DI-MIGRATION.md: the factory's last plain-function reservation
  import is drained.
2026-07-27 19:47:09 +02:00
jubnl 203d67c45f chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native
ReservationsService — byte-identical SQL, statuses, bodies, error strings
and broadcasts; the legacy file is deleted.

- reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation,
  reorder_reservations, link_hotel_accommodation) + the trip-reservations
  resource move to the decorator registry (identical names, schemas,
  annotations, error strings, broadcasts); the canWrite/canRead registration
  gates become declarative access markers. Legacy registrar and resource
  entries removed.
- reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the
  outside-container consumers (tripService, airtrail import/sync, the
  still-legacy transit + transports registrars, transitItineraryService's
  type-only import).
- notifyBookingChange folds to the legacy (tripId, actorId) signature; the
  controller passes user.id and the plugin-host factory drops its last
  plain-function reservation import for a private method over the injected
  service (constructor unchanged).
- TripsService and BookingImportService inject ReservationsService instead of
  importing legacy functions.
- Tests: reservations.service.test.ts rewritten as a real-SQL suite
  (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim,
  RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the
  DI-native pattern; path mocks repointed to the bridge or constructor stubs;
  tools-reservations kept and extended with the moved resource cases.

Quirks preserved on purpose (parity first — see the follow-up fix commit):
the raw-accommodation_id metadata-sync gate, the un-transactioned
multi-statement writes, the truthy updatePositions dayId check, the
empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
2026-07-27 19:47:01 +02:00
jubnl c27fc99220 feat(server): adopt zod DTOs for the reservation body contracts
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.

reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.

AccommodationsController stays grandfathered until the dayService migration.
2026-07-27 19:45:41 +02:00
jubnl fbc773386f fix(server): fix the quirks preserved by the vacay DI migration 2026-07-27 18:39:53 +02:00
jubnl e3547ec43b docs: tick vacayService in migrate.md and refresh the migration graph 2026-07-27 18:39:52 +02:00
jubnl 588457710c chore: move vacay backend to proper DI 2026-07-27 18:39:52 +02:00
jubnl 29e89a5774 feat(server): adopt zod DTOs for the vacay body contracts 2026-07-27 18:39:47 +02:00
jubnl a8d6663dc7 fix(client): send multiple_choice from the desktop poll form 2026-07-27 00:27:12 +02:00
jubnl 07b8f1234c fix(server): fix the quirks preserved by the collab DI migration 2026-07-27 00:27:12 +02:00
jubnl 20882f47b7 docs: tick collabService in migrate.md and refresh the migration graph 2026-07-27 00:27:12 +02:00
jubnl 49197c5370 feat(server): adopt zod DTOs for the collab body contracts 2026-07-27 00:27:11 +02:00
jubnl c08c595b7e chore: move collab backend to proper DI 2026-07-27 00:27:11 +02:00
jubnl 470e4dc92c feat(shared): make collab note optional fields nullable in the request contracts 2026-07-27 00:27:06 +02:00
jubnl f69731b72b docs(server): chronicle the fileService DI migration 2026-07-26 23:19:32 +02:00
jubnl f3e5be78c9 chore: move files backend to proper DI
Folds the legacy services/fileService.ts into the DI-native FilesService
(injected DatabaseService, byte-identical SQL). Load-time constants move to
files.constants.ts; files.bridge.ts survives with a single export for the
request-time getAllowedExtensions read the module-scope multer configs need
outside DI. TripsService and PluginHostDepsFactory inject FilesService.

Also rides the nestjs-zod ratchet (FileUploadDto/FileUpdateDto/FileLinkDto
over the @trek/shared contracts, FilesController entries dropped from the
body-contract allow-list) and fixes the verified legacy quirks: file-link
insert failures now surface instead of being swallowed, updateFile coerces
an empty-string description to NULL like createFile, and formatFile's
misleading optional trip_id annotation is gone.
2026-07-26 23:19:26 +02:00
jubnl dd3c03ba0f feat(shared): add fileUploadRequestSchema for the files upload body 2026-07-26 23:19:04 +02:00
jubnl 4a7d9cd0be refactor(plugins): route injectable-class SQL through the injected DatabaseService
PluginRuntimeService's operatorEgressHosts/pruneErrorLog and PluginsService's
egressHostCount were module-level helpers on the raw db proxy; they fold into
their classes as methods over the injected service. (PluginHostDepsFactory's
equivalent conversion rode the settings commit — same files.) No injectable
class under src/nest imports the db global anymore; the remaining importers
are the documented module-level seams (bridges, platform shell,
plugin-host-state, signature-status, the bare plugin user-setting readers).
2026-07-26 22:24:24 +02:00
jubnl e0530680cb feat(server): adopt zod DTOs for the settings body contracts
SettingUpsertDto/SettingsBulkDto wrap the existing @trek/shared schemas; both
SettingsController entries drop off the body-contract allow-list (boot
ratchet). Per the todo precedent the dead bespoke 400 guards go: missing/empty
key and a non-record settings body now answer with the pipe envelope
({ error: 'key: …' } / { error: 'settings: …' }) instead of 'Key is required' /
'Settings object is required', and arrays for settings are rejected. The local
masked-value literal now imports MASKED_SETTING_VALUE from @trek/shared.
The settings e2e registers ZodValidationPipe and pins the DTO 400s plus the
bulk masked-sentinel skip.
2026-07-26 22:24:14 +02:00
jubnl cece6509b9 chore: move settings backend to proper DI
Folds the legacy services/settingsService SQL into the Nest SettingsService
(byte-identical statements; BEGIN/COMMIT blocks become db.transaction()).
AdminService and ShareService inject it; llm-config.resolver becomes the
injectable LlmConfigResolver (SettingsService + DatabaseService), injected by
LlmParseService and PluginHostDepsFactory. No MCP surface, no bridge.

Whole-file commit, so this also carries the post-migration quirk fixes that
live in the same files: null serializes as '' (a stored "null" leaked out of
getDecryptedUserSetting as the literal string), bulk upserts skip the masked
sentinel (mirroring the single-upsert no-op) and return the written count.
Unit suite moved to tests/unit/nest with SET-SVC-001..026 preserved,
027..030 pin the fixes; settings/admin e2e converted to DI-native.
2026-07-26 22:23:57 +02:00
jubnl c9a9df5310 fix(server): harden share links — transactional writes, TTL renewal on update, scoped shared queries 2026-07-26 21:25:47 +02:00
jubnl d572b1ec8d feat(server): adopt zod DTOs for the share-link body contract 2026-07-26 21:25:34 +02:00
jubnl 72c3edda1a chore: move share backend to proper DI 2026-07-26 21:24:57 +02:00
jubnl efd0e7fa98 fix(server): import MCP session revocation from sessionManager, not the mcp barrel 2026-07-26 20:34:21 +02:00
jubnl 41b51849b7 feat(server): adopt zod DTOs for the assignment body contracts 2026-07-26 20:34:06 +02:00
jubnl 164f5152be chore: move assignments backend to proper DI and harden its writes 2026-07-26 20:33:31 +02:00
jubnl 9650ad4597 feat(server): adopt zod DTOs for the trip-invite body contracts
New tripInviteLinkCreateRequestSchema in @trek/shared (single source of
truth), a createZodDto wrapper on the controller's raw @Body(), and the
TripInviteLinkController.create entry dropped from the body-contract
allow-list (the boot ratchet enforces it).

expires_in_days accepts number | digits-only string | null | absent — the
string branch is digits-only so garbage like "7abc" is a 400 instead of
silently parseInt-ing to 7; the empty string (blank form input, meaning no
expiry) stays accepted. The e2e registers ZodValidationPipe and pins both
the bounded-expiry and 400 paths.
2026-07-26 18:21:18 +02:00
jubnl cb0cd3300d chore: move trip-invite backend to proper DI
Fold the legacy tripInviteService SQL into the Nest TripInviteService over
the injected DatabaseService (byte-identical statements; the JS-side expiry
check and non-positive-means-no-expiry coercion preserved) and delete the
legacy module. No bridge, no MCP port and no plugin-host change needed —
nothing outside the container consumed this domain.

Also wraps the rotation's probe + write + re-select in db.transaction()
per the repo rule that multi-statement writes are never bare.

Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and
adding 007 (expiry pinning); the e2e converted to the DI-native pattern
(real SQL against temp trips/trip_invite_tokens tables).
2026-07-26 18:21:04 +02:00
jubnl 3762d3306c feat(server): adopt zod DTOs for the day-note body contracts 2026-07-26 17:44:25 +02:00
jubnl 4e49e9c21e chore: move day-notes backend to proper DI 2026-07-26 17:44:12 +02:00
jubnl 7242812b27 fix(trips): scope the offline bundle's packing items to the viewer (#858) 2026-07-26 16:57:02 +02:00
jubnl 934848e114 fix(packing): correct legacy quirks kept during the DI migration 2026-07-26 16:56:18 +02:00
jubnl 5da2abf4f9 feat(server): adopt zod DTOs for the packing body contracts 2026-07-26 16:55:53 +02:00
jubnl f1960dc364 chore: move packing backend to proper DI 2026-07-26 16:55:20 +02:00
jubnl 67b3005d36 docs(plugins): record Option A as implemented, correct bridge/next-up drift 2026-07-26 15:29:00 +02:00
jubnl 811230fa5d refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges 2026-07-26 15:28:55 +02:00
jubnl 167db04d69 refactor(plugins): split create-rpc-host into an injectable factory + host-state module 2026-07-26 15:28:39 +02:00
jubnl 6c583c2f27 feat(server): adopt nestjs-zod with a fail-closed body-contract boot gate
- One global ZodValidationPipe (APP_PIPE, createZodValidationPipe) validates
  any @Body() typed with a createZodDto class; the custom exception factory
  keeps TREK's { error: 'field: message; ...' } envelope byte-identical.
- The 7 piped controllers (collections, places, vacay, todo, health,
  airtrail x2) now type bodies with colocated <domain>.dto.ts wrappers —
  the Zod schemas in @trek/shared stay the single source of truth.
- OpenAPI: the hand-rolled api-zod.ts enricher is replaced by nestjs-zod's
  cleanupOpenApiDoc; request bodies surface as $refs to named DTO schemas.
- validateBodyContracts() runs in buildApp() after init and refuses to boot
  when any POST/PUT/PATCH @Body() (including @Body('field') sub-reads) lacks
  a ZodDto metatype. The 175 pre-existing legacy handlers are grandfathered
  in body-contract-allow-list.ts; the list is ratchet-only — the gate also
  throws on stale entries, so migrating a domain forces their removal.
2026-07-26 14:55:13 +02:00
jubnl 8e6b3a424e style(shared): apply eslint --fix line wrapping to locale and schema files 2026-07-26 14:54:59 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
jubnl ad7d349c85 fix(todo): wrap category-assignee replacement in a transaction
updateCategoryAssignees ran its DELETE + INSERT OR IGNORE loop unwrapped, so
a mid-loop failure could leave a category half-cleared. Also widens the item
params to the nullable shapes the wire contract actually sends (prep for the
todo body validation).
2026-07-26 14:54:38 +02:00
jubnl 79946cc711 chore: move todo backend to proper DI 2026-07-26 14:09:54 +02:00
Maurice b5ebffa115 fix(dashboard): keep a trip the hero fell back to in the grid
The hero picks the trip that is running, else the next one coming up, else
just trips[0] — and that pick was then removed from the grid below. The first
two cases are deliberate: the trip is already on screen, showing it again
right underneath would be noise. The trips[0] fallback is a different thing.
It fires precisely when nothing qualifies, so it borrows a trip that belongs
in the grid, and removing it there leaves the user with a hero and an empty
list. Someone whose trips are all finished sees "No trips yet" under a card
of the trip they just came back from.

Split the two: only a trip that earned the hero is taken out of the grid.
Undated trips were caught by this too, and worse — getTripStatus returns
null rather than 'past' for them, so a lone dateless trip appeared in no
tab at all.

The empty state was the second half of the report. It equated "planned is
empty" with "you have no trips" and told people to create their first one.
Gate it on there being no trip at all, which is the condition the mobile
dashboard already uses.

Mobile shares useDashboard, so it is fixed along with the desktop.

Closes #1706
2026-07-26 12:45:54 +02:00
jubnl 2ce40724d7 chore: move category backend to proper DI 2026-07-26 12:36:30 +02:00
Maurice c8369bd14b fix(plugin): clear brokered session state on logout
sessionStorage outlives a logout inside the same tab, so the keys stay readable
after the next user signs in. They are namespaced by user id, which stops the new
user's plugins from reading them, but the data is still sitting in the tab — the
same reason logout already drops the appearance snapshot, the SW caches and the
user-scoped offline DB. Purge them there too.

Three things around it, from reading the handlers next to the new ones:

- The inactive-plugin purge ran before the store was updated, so a sessionStorage
  failure cost us the plugin list we had just fetched. Commit first, purge after.
- The session handlers answered without checking requestId, unlike confirm and
  geolocation — an answer the frame can never match is worth skipping.
- The mock host threw plain Errors while the real bridge rejects with .code set,
  and checked the key before the trip scope where the host does the reverse, so a
  plugin's error handling could pass its tests and still break in the app.
2026-07-26 11:48:15 +02:00
geracobo 2068133de2 feat(plugins): add quota-limited session storage API 2026-07-26 11:48:15 +02:00
geracobo cc8e4d480e fix(plugins): corrected non-sequential test numbers for PluginFrame 2026-07-26 11:48:15 +02:00
geracobo 9d8d0b1653 feat(plugins): add session storage api for plugin bridge 2026-07-26 11:48:15 +02:00
Maurice 0fc04909a8 docs(plugin): spell out what blurBookingCodes does, and preview it
The context table listed the new field without saying what a plugin is meant to
do with it. It is a display hint — booking codes still arrive in full over
trek:invoke — and reading it as redaction is the one mistake worth preventing.

The dev preview hardcoded it to false, so the blurred path was the only host flag
an author could not try out; it gets a toggle like reduce motion and the rest.
2026-07-26 11:48:06 +02:00
geracobo 16793761be fix(plugin): include blur booking codes on the plugin context 2026-07-26 11:48:06 +02:00
Maurice c4f4fdd794 fix(booking-import): cover the AI retry and keep its source files
The widget's new healthApi.features() call left BackgroundTasksWidget.test.tsx
red — the api/client mock never exported healthApi, so both existing tests threw
on mount. The mock now carries it, pending by default like the poll backstop, and
the retry itself finally has tests.

Writing them turned up two gaps. The retry never called saveImportFiles, so the
files only lived in memory: reloading during the retry parse left the review with
no source document to attach to the bookings it creates, unlike every import
started from the modal. And the button stayed live while the request was in
flight, so a second click started a second force-ai job on the same files.
2026-07-26 11:47:59 +02:00
Xre0uS 6854436c24 feat(booking-import): retry a failed import with AI parsing 2026-07-26 11:47:59 +02:00
Maurice eae2ade740 fix(booking-import): only resolve leg days from local_date on a prefill
Resolving every waypoint day from endpoint.local_date also changed the edit path,
where it inverts the precedence the form uses everywhere else: local_date is
denormalised and the server only restamps it in the day_id branch of insertDay,
and only when reservation_time is set. Dragging a booking to another day rewrites
day_id/end_day_id and leaves the endpoints alone, so local_date points at the old
day — and reopening the form and pressing Update silently moved the booking back.

Gate the lookup on a prefill, where there is no saved day to lose, and the edit
path behaves exactly as it did before. Same change in the mobile sheet, which
shares the seeding code and would otherwise still show empty day selectors on an
imported booking.
2026-07-26 11:47:52 +02:00
Xre0uS 68ebc700cb fix(booking-import): pre-fill leg dates from endpoint local_date 2026-07-26 11:47:52 +02:00
jubnl 8f3ee53841 feat(mcp): Create nest wrapper for MCP for proper DI (#1708) 2026-07-26 11:42:47 +02:00
Maurice 828c1db489 fix(offline): start the tile prefetch once the app is idle
syncAll runs straight off the back of a successful login, while the app
is still mounting its first screen. Kicking off a bulk background
download at that exact moment is the worst possible timing even with the
prefetch throttled, so hold the tiles back until requestIdleCallback
says the browser has nothing better to do.

Files keep their old behaviour — they are a handful of requests, not
thousands.
2026-07-26 10:54:22 +02:00
Maurice 3c67afeade fix(offline): throttle the map tile prefetch
The prefetch dispatched every tile of a trip's bounding box in one
synchronous loop — up to MAX_TILES per trip. A trip spanning a few
countries enumerates thousands of tiles at zoom 10 alone, so logging in
fired ~8k no-cors requests back to back and kept them coming for half a
minute. All of them go through the Workbox fetch handler, which then has
to write a cache entry plus an expiration record per tile, so everything
the app itself requested queued up behind the burst and the UI just sat
there.

Run the queue through a small worker pool instead, and let it stop early
when the user goes offline or logs out.

Two things fall out of that:

- Tiles already in Cache Storage are now skipped before the request is
  made, so a resumed or repeated prefetch over a warm cache is nearly
  free.
- {s} has to be a pure function of the tile coordinates for that lookup
  to hit. The rotating counter gave the same tile a different host on
  every run, which also meant caching it up to four times.
2026-07-26 10:54:22 +02:00
Maurice a3e2ff9fa5 fix(mobile): offer the currency on the dashboard trip sheet
The mobile create/edit sheet never showed the currency, so every trip
started from the dashboard silently took the server default of EUR and
could only be corrected from the trip planner, which reuses TripFormModal
and does have the field.

Same control as the desktop form — searchable select over currenciesWith(),
gated on trip_edit like the rest of the sheet.
2026-07-25 22:04:03 +02:00
jubnl 53e884ad55 refactor(nest): migrate tagService into TagsService DI (pilot)
Move the tag SQL from src/services/tagService.ts into TagsService over an
injected DatabaseService (identical statements, #10b981 default, COALESCE
semantics). Non-Nest consumers (MCP tools, plugin RPC host) go through the
new tags.bridge.ts, which exports the legacy function names 1:1 from a
module-level instance over the shared db Proxy — the settled pattern for
the remaining legacy-service migrations (recipe documented in
src/nest/README.md). Tests are DI-native: direct service construction in
unit (with bridge delegation cases for the coverage gate) and
DatabaseModule + real SQL in e2e.
2026-07-25 18:36:17 +02:00
jubnl dd2c2d6668 refactor(nest): inject DatabaseService in db-using controllers 2026-07-25 18:15:15 +02:00
jubnl fbe200424d refactor(nest): inject DatabaseService in db-using services 2026-07-25 18:15:04 +02:00
jubnl def6b84938 refactor(nest): inject DatabaseService in access-check services and controllers 2026-07-25 18:14:53 +02:00
jubnl 718022c7ba feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface 2026-07-25 18:14:37 +02:00
jubnl 7059b686ac Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 17:02:55 +02:00
jubnl 45c1568e7c chore: correct user setting currency i18n 2026-07-25 17:02:32 +02:00
Maurice c7a67ed58a fix(build): align the musl sharp pins with the sharp version
The bump to sharp 0.35.0 left the @img/sharp-linuxmusl-* pins on 0.35.1,
so npm nested the 0.35.0 binary under node_modules/sharp while hoisting
libvips to the root. The nested .node then looks for libvips-cpp.so.8.18.3
next to itself, finds nothing, and the Alpine image build dies in the
client prebuild:

  Could not load the "sharp" module using the linuxmusl-x64 runtime
  ERR_DLOPEN_FAILED: Error loading shared library libvips-cpp.so.8.18.3

Neither docker workflow runs on push, so this only surfaces when a release
is cut. Pin both binaries to 0.35.0 so they hoist next to libvips again.
2026-07-25 16:46:29 +02:00
jubnl 8a46c56a96 test(client): dismiss the CTA-style support modal in e2e setup 2026-07-25 16:30:33 +02:00
jubnl e162da6dbe fix(server): resolve zh-TW default language, close lint gap on bracket env access 2026-07-25 16:19:15 +02:00
jubnl f539c2aab7 Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 16:06:29 +02:00
jubnl 0f3a6f80b7 chore(server): forbid direct process.env access outside app-config 2026-07-25 16:05:26 +02:00
jubnl 3b31f4256b refactor(server): move language/session parsing into app-config, config.ts becomes keys module 2026-07-25 16:01:42 +02:00
jubnl fb9abdb76c refactor(server): non-DI islands read env via app-config 2026-07-25 15:58:02 +02:00
jubnl 7f795a2199 refactor(server): legacy services read env via app-config 2026-07-25 15:37:37 +02:00
jubnl d13758221e refactor(server): remaining nest domains read env via app-config 2026-07-25 15:29:13 +02:00
jubnl 9e87e09d03 refactor(server): auth, oidc and admin nest modules read env via app-config 2026-07-25 15:21:17 +02:00
jubnl a34ab288f5 refactor(server): bootstrap and middleware read config via app-config 2026-07-25 15:17:33 +02:00
jubnl e17f165e44 feat(server): add nest AppConfigModule bound to @nestjs/config 2026-07-25 15:12:38 +02:00
jubnl 48ba9092c3 feat(server): add app-config core with fail-fast env validation 2026-07-25 15:03:27 +02:00
Maurice 39d4dc2675 feat(vacay): thin the mobile person card down to the number you act on
Used, carried over, comp days and remaining all fit a desktop sidebar; on a
phone they collapsed into four scraps of 9px text fighting for one row. Only
the balance is left, as a pill tinted by how much of it there is — the rest is
still a tap away in the entitlement view.

The toolbar chip says "Company" rather than "Company Holiday": it sits beside
two other buttons and the building icon already carries the meaning. The
allowance stepper reads "Days" instead of "days / year", reusing the label the
desktop entitlement tile has always used — the year is on the pill above it.
2026-07-25 14:49:38 +02:00
Maurice bccf4284c4 i18n(login): drop the keys the feature tiles used
login.features.* and login.selfHosted across all 23 locales — nothing renders
them any more.
2026-07-25 14:49:38 +02:00
Maurice 8b6a80fce0 feat(login): rebuild the branding panel and the sign-in moment around it
The panel dropped the eight feature tiles and the self-hosted line — they read
as a spec sheet on a screen whose job is to get you in. What is left is the
mark, the tagline and one line of copy, centred over the map, each with a soft
dark halo so the dots cannot eat the type. The tagline and subtitle never wrap:
they scale with the panel instead, since it is a fixed share of the viewport.

The takeoff after sign-in used to tell a different story than the screen it
came from — cloud ellipses, a lone plane, a contrail. It now picks the panel up
and escalates it: the same map, every route departing at once, the logo landing
as the network completes, then a soft hand-off into the app.
2026-07-25 14:49:38 +02:00
Maurice 45ce365920 feat(login): a living world map behind the sign-in panel
Replaces the drifting plane silhouettes with the thing TREK actually does:
routes lighting up between cities across a dot map of the world. Arcs leave a
city, a light travels the curve, the destination lands, and the arc retires to
make room for the next.

The map is drawn once into an offscreen layer and blitted per frame, so only
the handful of live arcs cost anything — no shadowBlur in the frame path. The
same component covers the sign-in moment via `variant="takeoff"`, where the
network fires as one cascade and stays lit instead of cycling.

Reduced motion gets the map as a still frame.
2026-07-25 14:49:38 +02:00
Maurice 0d9032224d feat(login): bake TREK's coastlines into a dot grid for the sign-in screen
The login screen is unauthenticated, so it cannot call the Atlas endpoint the
rest of the app uses for country geometry. This is that same admin-0 bundle,
sampled along the coastlines onto a 300x150 grid and delta-encoded — 15 KB
instead of a 28 MB fetch nobody is allowed to make yet.
2026-07-25 14:49:38 +02:00
dependabot[bot] b955998905 chore(deps-dev): bump sharp from 0.33.5 to 0.35.0 (#1690)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump sharp from 0.33.5 to 0.35.0

Bumps [sharp](https://github.com/lovell/sharp) from 0.33.5 to 0.35.0.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.33.5...v0.35.0)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:14:41 +02:00
dependabot[bot] 50c0864bce chore(deps): bump hono from 4.12.26 to 4.12.32 (#1692)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump hono from 4.12.26 to 4.12.32

Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.32.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.32)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.32
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:12:58 +02:00
dependabot[bot] 60f888dc71 chore(deps): bump body-parser from 1.20.5 to 1.20.6 (#1693)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump body-parser from 1.20.5 to 1.20.6

Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.5 to 1.20.6.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.5...1.20.6)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:11:09 +02:00
dependabot[bot] 3b98721a64 chore(deps): bump shell-quote and concurrently (#1691)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump shell-quote and concurrently

Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together.


Updates `shell-quote` from 1.8.4 to 1.9.0
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.9.0)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.9.0
  dependency-type: indirect
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:09:45 +02:00
dependabot[bot] 8bf8da18f6 chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk (#1688)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:08:16 +02:00
dependabot[bot] 65913975d7 chore(deps): bump fast-uri from 3.1.2 to 3.1.4 (#1689)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fast-uri from 3.1.2 to 3.1.4

Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:05:51 +02:00
jubnl f1489534bb chore: bump dompurify to 3.4.12 2026-07-25 13:02:27 +02:00
jubnl e60427f813 Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)
This reverts commit 94736112cf.
2026-07-25 13:00:55 +02:00
dependabot[bot] 94736112cf chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 12:58:59 +02:00
Maurice fbd2d17fa8 docs(vacay): document the leave year and the comp/half modifiers
The page still described Vacay as strictly January to December and never
mentioned half days at all, so both new features would have shipped
undocumented.
2026-07-25 12:02:16 +02:00
Maurice 3c28c7eaeb test(vacay): cover the leave-year window and the comp exclusion
Windows for all three year types, the fallbacks (anniversary without a hire
date, a day the month cannot have), usage counted across the calendar-year
boundary, carry-over chained over periods rather than years, deleteYear over a
shifted period, and the reads with and without a viewer.

For comp: excluded from usage, a half comp day still costing nothing,
converting a vacation day back, and comp_used reported on its own.
2026-07-25 12:02:16 +02:00
Maurice 59128566d8 i18n(vacay): translate the leave-year and comp keys 2026-07-25 12:02:16 +02:00
Maurice b37996ec39 feat(vacay): make the toolbar show the marker it places (#1074)
The half-day button carried a ½ glyph and comp/flex a clock, neither of which
appears anywhere on the calendar. Both now show what a click actually puts on
the day: the orange dot for a half day, a hatched disc in the selected person's
colour for comp.

The digit on an all-comp cell also needed help. The hatch lets the surface
through between its stripes, so a white number sank into it — a shadow carries
the contrast now and every logged day keeps the same digit colour. Mixed days
still have a solid segment under the number and get none.
2026-07-25 12:02:16 +02:00
Maurice f1d520bbc2 feat(vacay): show comp days in the stats and fold the card away (#1074)
The server has returned comp_used since the leave type landed, but nothing
showed it — comp days simply vanished from the sidebar. They now sit beside
the tiles rather than inside the used/left arithmetic, which is the point of
them.

Carry-over is labelled from the window the server counted that row over, not
from the viewer's own setting, so a fused plan with mixed leave years does not
tell one member their days came "from 2025" when they came from a period that
started in July.

The card also folds now. It is the longest thing in the sidebar once several
people are fused in, and the state belongs to the person looking at it, so it
lives in localStorage rather than on the plan.
2026-07-25 12:02:16 +02:00
Maurice d984e09473 feat(vacay): pick a leave year and roll the grid over with it (#737)
Calendar, fiscal (month + day) or from the hire date, per user — it sits with
the plan settings but belongs to the person, so fused members each keep their
own and the grid follows whoever is looking at it.

Both grids render twelve months from the window start with year rollover
instead of a fixed Jan–Dec, and the mobile month navigation moves through slots
of that window, so stepping past December lands in January of the same period
rather than jumping back to the start of the calendar year. The month effect
keys on the window's shape rather than the settings object, which the store
replaces with an equal copy on every refresh — otherwise any sync would throw
away the month the user had open.

Known edge for a start day past the 1st: the cards are month-aligned, so the
first days of the start month are drawn but count toward the previous period.
Counting stays day-exact.
2026-07-25 12:02:16 +02:00
Maurice e1c5daeccb feat(vacay): mirror the leave-year window in the client (#737)
The server owns the arithmetic entitlements are computed with, but the grid has
to render the same twelve months and the overlays have to filter against the
same range, so the window resolves on both sides. yearWindow.ts is that mirror,
including the Feb-29 clamp and the anniversary-without-a-hire-date fallback.

The store loads the settings before the year list picks a default — with a
shifted year the current period is not necessarily the current calendar year.
Holidays are fetched per calendar year the window touches and clipped back to
what the grid draws; a school break straddling New Year comes back from both
fetches, so markers are de-duplicated per day. The label is part of that
identity, otherwise two calendars left on the same colour collapse into one.
2026-07-25 12:02:16 +02:00
Maurice 9d93ed95bf feat(vacay): read and write the leave year over its own window (#737)
Adds GET/PUT /addons/vacay/year-settings and makes the reads window-aware, so
the backend from the previous commit is actually reachable.

Entries and shared calendars now load over the caller's window instead of a
date prefix — a shifted year spans two calendar years and the prefix silently
dropped the second half. Reads are month-aligned (the grid draws month cards),
while entitlement stays day-exact; the two coincide for every window starting
on the 1st, which is all of calendar. deleteYear clears entries per author over
that author's period, and company holidays only over the range every member
shares, so one member deleting a period cannot reach into another's live one.
getStats reports the window it counted, and getOwnPlan seeds the period today
falls into rather than the calendar year.

A start day the month cannot have (Feb 29 from a hire date, a stored Feb 31)
is clamped to one every year has — otherwise the boundary string matches no
date and the whole window slides. Anniversary before a hire date is entered
resolves to the calendar year instead of borrowing a month a previous fiscal
setting left behind.

MCP reads pass their user through; without one the range stays the plain
calendar year, so plan-scoped callers are unchanged.
2026-07-25 12:02:16 +02:00
Maurice 2672a7a741 test(vacay): catch the suites up with the comp/flex column
The kind column changed four things the tests still asserted the old way:
toggleEntry gained a parameter, the toolbar gained the comp modifier, shared
calendar rows carry kind, and a multi-person cell is now segment overlays
instead of one split gradient. splitBackground went with it — nothing but the
test still referenced it.

The toolbar tests picked the half-day button by index, which the new button
shifted; they select by name now so the next modifier does not break them.
2026-07-25 12:02:16 +02:00
Maurice c475e5b04c feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
2026-07-25 12:02:16 +02:00
Konstantinos Thermos c61203e607 fix(reservations): restrict all booking dates to the trip span 2026-07-24 19:16:55 +02:00
Maurice 8769ed084e refactor(reservations): move desktop traveler assignment into the edit/create modals
The always-on card picker is gone — travelers are assigned in the booking and transport modals now, matching mobile. The card shows assigned travelers read-only as pills in the grey field box, TravelerChips is replaced by a shared TravelerPicker, and the toolbar filter gets gradient avatars with an accent ring and check badge.
2026-07-24 16:50:41 +02:00
Maurice 2a5c54c025 feat(reservations): traveler filter on the mobile bookings and transports tabs 2026-07-24 16:50:41 +02:00
Maurice bc06125d6e test(reservations): server + client coverage for traveler assignment 2026-07-24 16:50:41 +02:00
Maurice e89dae5528 feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers) 2026-07-24 16:50:41 +02:00
Maurice 8d7753c2e1 i18n(reservations): translate the traveler keys into all locales 2026-07-24 16:50:41 +02:00
Maurice 781c693434 feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop) 2026-07-24 16:50:41 +02:00
Maurice 21456b983e feat(reservations): assign trip members and guests to bookings — backend (schema, migration, travelers endpoint) 2026-07-24 16:50:41 +02:00
Maurice 9344d4508c chore(deps): regenerate lockfile so json5 is the only change and the peer markers stay intact 2026-07-24 12:42:38 +02:00
fbnlrz 9ad3507e57 fix(llm-parse): tolerate non-strict JSON from Gemini and similar models
Gemini reached through the OpenAI-compatible endpoint returns
JavaScript-object-literal output — single-quoted strings, unquoted keys,
trailing commas — which strict JSON.parse rejects. The reservation list
then came back empty and the booking-import UI showed nothing (#1638).

Add a shared parseLenientJson() that tries strict JSON first and falls
back to JSON5 (which accepts exactly that relaxed superset), and route
both the OpenAI-compatible and Ollama-native parse paths through it,
replacing the two identical strict parsers.
2026-07-24 12:42:38 +02:00
Maurice 791468e5b3 fix(airtrail): also treat an endpoint-count > 2 booking as a local multi-leg shape, mirroring the server so a locally-grown chain is not mislabelled removed 2026-07-24 12:28:49 +02:00
fbnlrz 3dc9ac6ac6 fix(airtrail): stop labelling a merged multi-leg import "removed in AirTrail"
A multi-leg flight imported "as one flight with a layover" is detached
from sync by design — AirTrail has no single flight to round-trip a
layover chain to, so it is created with sync_enabled=0 (#1535). The
reservations badge collapsed every sync_enabled=0 AirTrail row into "Not
synced" with the tooltip "This flight was removed in AirTrail", which is
false for a merged booking that was never removed (#1646).

Give the multi-leg case (metadata.legs.length > 1) its own state: show
the normal AirTrail badge with a truthful tooltip explaining it stays a
one-time import. A genuinely removed single-leg flight still shows "Not
synced". Adds reservations.airtrail.layoverHint across all locales.
2026-07-24 12:28:49 +02:00
Maurice 68b0c6bceb fix(settings): mask inherited encrypted admin defaults so llm_api_key never reaches the client in cleartext 2026-07-24 12:26:58 +02:00
fbnlrz 4b73fa80a9 fix(settings): let an empty user value fall back to the admin default
The admin "user defaults" merge kept any user value that was merely
present, so an empty string shadowed the system-wide default. The
client's Settings save always writes every field — a blank Mapbox token
included — so a user who ever opened Map Settings got '' instead of the
admin token, and clearing their own token could never restore the
fallback (#1634).

Treat an empty/null user value for a defaultable key as "not set" when an
admin default exists, so it falls through. Non-defaultable keys and keys
without an admin default keep their exact stored value.
2026-07-24 12:26:58 +02:00
yu 617ffae351 feat(journey): add mobile quick capture with location and weather 2026-07-24 12:06:29 +02:00
Maurice 60abcf6598 feat(dashboard): drop the play circle from the beacon, keep the copy and the badge 2026-07-24 11:49:11 +02:00
Maurice 51f8afdb60 feat(dashboard): retire the 4.0.0 moment after Aug 23 — viewable-until badge + a permanent dismiss X 2026-07-24 11:49:11 +02:00
Maurice 2cec06161d fix(dashboard): drop the frame shake — the boom and the shatter carry the climax on their own 2026-07-24 11:49:11 +02:00
Maurice 0834282bfd feat(dashboard): five signature text moments + widget-matched corners
- The finale condenses out of light: ~1000 particles rise from the world and
  gather into the anthem's letterforms before the crisp DOM text takes over;
  the language cascade waits for the word to settle.
- Replaced fear-act lines decay letter by letter — the words of fear fall
  apart instead of vanishing.
- The climax boom rocks the whole frame for half a second, in sync with the
  sub impact and the border shatter.
- Hope-act lines reveal sentence by sentence, in speech rhythm.
- The standing anthem breathes — a barely-there scale and glow pulse.
- Beacon corners now use the dashboard tool widgets' --r-xl radius.
2026-07-24 11:49:11 +02:00
Maurice ffda4de77d feat(dashboard): the anthem IS the ending + beacon final polish + navbar fix
The show now ends standing: KEINE ANGST with the full 23-language cascade
stays up over the lit world, music carrying on until the traveler closes it —
no credits hand-off. Skip jumps straight to that standing frame.

Beacon: dismiss × removed, copy block centered on the canvas, the title gets
its quiet jewelry treatment — ivory-to-gold fill, a traveling sheen and a slow
breathing glow.

The overlay now portals to document.body: the sticky dashboard sidebar forms
its own stacking context, so the fixed navbar (z-200 at root) used to float
above the show regardless of the overlay's z-index.
2026-07-24 11:49:11 +02:00
Maurice 1cf1a9ca42 fix(dashboard): end screen drops the inspiration credit and the close button — anthem, three lines, done 2026-07-24 11:49:11 +02:00
Maurice 406229e701 fix(dashboard): flash words become stamped title cards — chromatic fringes, micro-jitter, hard cut, no zoom 2026-07-24 11:49:11 +02:00
Maurice d2d8b65827 fix(dashboard): soften the pivot into 'but you have traveled' + audio review fixes
The transition out of the fear act was too brutal: the silence now arrives as
a fast pull-away with the hall tail ringing out instead of a 100ms kill, the
pivot line trades its impact boom for a slow warm A1 swell (the first calm
breath), and the line itself breathes in over 3.4s.

Audio review fixes: the wind's swell LFO moves to a series tremolo stage so
the release can actually silence it before the cut (it used to warble through
the silence and click on stop); the heartbeat's noise layer becomes the
intended high-passed click instead of a dull lowpassed thud; dispose() pulls
the master to zero before the deferred context close so leaving the show
cannot pop.
2026-07-24 11:49:11 +02:00
Maurice ba2582f9c8 feat(dashboard): beacon goes cinema-poster — centered copy, iconic play circle
Copy sits centered on the night-world canvas over a corner-pooling scrim; the
title grows monumental tracking, a gold sheen and a soft glow; the text CTA
becomes a slowly pulsing golden play circle that fills on hover. The version
eyebrow and the red live dot are gone — title, one line, play. Nothing else.
2026-07-24 11:49:11 +02:00
Maurice 920628d63c feat(dashboard): show polish — readable lines, gliding arcs, a real hall
- Lines get a layered soft glow plus a radial pool of darkness under the text
  zone, so they hold up over the burning border map without a hard box.
- Arc heads now interpolate fractionally along the curve with ease-out growth
  (the whole-segment quantization read as stutter), in the show and the beacon
  teaser alike.
- The score gains a convolution reverb with a procedurally generated impulse
  response; the fear act trades raw saws for an organic bed (sine sub,
  drifting fifth, breathing noise rumble, a semitone grind in the dread act),
  the shimmer runs through a feedback echo, impact tails bloom in the hall,
  the heartbeat stays close and dry.
2026-07-24 11:49:11 +02:00
Maurice 10bfad97da feat(dashboard): cinematic beacon — living arc teaser, sheen title, scrim
The beacon is now a dark cinema card: a small canvas paints a breathing
night-world with golden travel arcs on loop, colored glows drift over it, the
title carries a gold sheen sweep and the copy sits on a bottom scrim. Core
colors ride inline so no dashboard widget CSS can wash the card out again.
2026-07-24 11:49:11 +02:00
Maurice c541c158df fix(dashboard): keep the generic line up through the stats slot for travelers with no places yet 2026-07-24 11:49:11 +02:00
Maurice aec541c573 feat(dashboard): make the KEINE ANGST show hit harder
Visual: city lights now come alive in the opening and die one by one while the
borders burn; three staccato words strobe with the heartbeat before the cut;
at the climax the borders no longer fade — they shatter into drifting sparks.
The traveler's own places ignite gold in the release act, with their real
numbers in the line ('{p} places. {c} countries. And not once did the world
hurt you.'). All heavy geometry is decimated to a vertex budget and pre-baked
into offscreen layers, so the frame path is a handful of drawImage calls — the
per-frame border stroke that dropped the show to ~1 fps is gone.

Sound: heartbeat with a click transient, wind textures, a doubled noise riser,
cue-driven sub-bass impacts and a gliding D–A–f#m–E progression with a sub
root under the anthem instead of the static pad.

Also: dashboard FAB hidden behind the show, suspended-AudioContext recovery
(Safari), tab-hidden pause for clock and audio, aborted geo fetch on close,
valid beacon markup (dismiss as sibling button), RTL-aware beacon, real
reduced-motion static frame, and native-speaker fixes for nl/sv/br/fr/it/es/
tr/gr copy.
2026-07-24 11:49:11 +02:00
Maurice c6d96872ae feat(dashboard): 4.0.0 release moment — KEINE ANGST
A one-release statement against racism, hatred and fascism, told the way TREK
knows the world: through travel. A pulsing beacon in the dashboard sidebar
(desktop only, dismissible) opens a ~80s full-screen show — the world as a map
of dots whose borders burn red and then dissolve under a growing web of travel
connections, driven by a fully procedural Web Audio score (heartbeat, drone,
A-major release; no audio assets, no copyrighted material) and hard-cut lines
in all 23 TREK languages, closing on the anthem cascade and a credit to the
song that sparked it (Danger Dan — Keine Angst).

Self-contained in components/FourZero (own copy dictionary, no shared i18n
churn); wired into the dashboard with two marked lines. Removing the folder
plus those two lines retires the moment after the release.
2026-07-24 11:49:11 +02:00
dependabot[bot] eb0dfc83c1 chore(deps): bump hono from 4.12.26 to 4.12.31
Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.31.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.31)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 11:38:38 +02:00
Konstantinos Thermos e100c52676 fix(maps): localize map POI pin titles to the user's language 2026-07-24 11:36:30 +02:00
Konstantinos Thermos fce26618a1 fix(transport): guard non-finite dates in trip-map duration 2026-07-24 11:36:24 +02:00
Elmer 7bd9e08016 feat(vacay): add school holiday overlays (#1633)
* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-07-23 22:48:42 +02:00
SimMesg20 4d3d16389e Feat/journey external picture provider rework (#1632)
* feat(journey): add contextual external photo browser

* fix(Journey) added missing language keys for new Journey feature

* fix(Journey) fixing missing gps requests aswell as added protection against duplicate entries
2026-07-23 21:35:12 +02:00
Maurice 8855b5581d feat(places): planned pool filter + hotel-transfer day routing (#1666)
* fix(dayplan): expose route tools on a hotel-to-hotel transfer day (#1297)

* feat(places): add a Planned filter to the places pool (sidebar, map, mobile)

* fix(places): stretch the mobile pool filter chips to fill the row
2026-07-23 21:28:00 +02:00
Maurice c5360be242 feat(dayplan): choose the travel mode per segment (#1281) (#1651)
* feat(dayplan): persist per-leg and per-day travel mode (#1281)

The day-plan route used one transport mode for the whole day, held only in
ephemeral state. Store it instead: leg_transport_mode on an assignment is
the mode of the leg leaving that stop (null = inherit the day default), and
days gains a default_transport_mode. Both nullable, so existing itineraries
are unchanged.

Adds PUT /assignments/:id/transport and PUT /days/:id/transport — their own
endpoints so setting the day default can't wipe notes/title the way the
general day update would — mirroring the assignment_time path.

* feat(dayplan): choose the travel mode per segment (#1281)

Each leg between two stops now routes with its own mode, so a day can mix
walking, driving and plugin-routed legs. Tap the connector between two
places to change that leg's mode (desktop popup / mobile sheet); the
whole-day Foot/Car picker sets the persisted day default and leaves
explicitly-set legs alone. The connector and map redraw in the chosen mode,
and each route segment is tagged with the mode it was drawn in.

New strings across all 23 locales.
2026-07-21 23:36:53 +02:00
Maurice ac723744ab feat(collections): add places by coordinates + sort A–Z (#1647)
* feat(collections): add places by coordinates and sort the list

Raised in #1640: collections behaved differently from trips — you
couldn't add a place by typing coordinates, and the list had no order
control.

The add-place modal and the place detail sheet now take an editable
address plus latitude/longitude, with a "lat, lng" paste shortcut, so a
place can be saved by GPS without searching. The place update path
learned to persist coordinates, so an existing one can be corrected
later.

A sort control in the filter row toggles between the saved order and
alphabetical by name. The add-place button moved into that row to match
the other controls, and the add-label button dropped its dashed one-off
styling. New strings land across all 23 locales.

* feat(collections): make the add-place button icon-only

Keep the filter row compact — the button carries just the plus icon (with
an aria-label/title), squared off to match the dropdown height.
2026-07-21 22:10:41 +02:00
Maurice 399e3573c1 feat(places): collaborative place ratings + collections MCP (#1435) (#1644)
Trip members can rate a place 1-5 stars; the place shows the average,
with a who-voted custom tooltip and a star-sort toggle in the places
sidebar. Saved collection places are ratable too (plus a min-rating
filter), and ratings travel with a place copied between a trip and a
collection — carrying only the votes of users who belong to both sides.

Adds a full Collections MCP surface (list/get, CRUD lists + places,
status, rate_collection_place, labels, sharing, copy-to-trip) under a
new collections:read/collections:write scope, plus rate_place for trip
places; rating aggregates flow through the existing place outputs.

Mobile trip + collection place sheets gain the rating row. New UI
strings translated across all 22 locales.
2026-07-21 21:00:26 +02:00
Konstantinos Thermos 88cb7f22dd fix(budget): match the add-expense input decimal separator to the list (#1645) 2026-07-21 20:36:47 +02:00
Maurice b8728078c4 feat(reservations): add a parking booking type (#1444) (#1642)
* feat(reservations): add a parking booking type (#1444)

* feat(reservations): parking type option, icon and color across the UI

* test(reservations): cover the parking booking type

* docs(reservations): document the parking booking type
2026-07-21 16:02:53 +02:00
Maurice 3678761a2d feat(places): custom place image (#1136) (#1641)
* feat(places): upload a custom place image on the server (#1136)

* i18n(places): custom place image labels

* feat(places): click a place thumbnail to set a custom image

* feat(places): custom place image on mobile place and collection sheets

* test(places): cover custom place and collection image upload

* docs(places): document custom place images

* fix(places): carry osm_id on the embedded place so day-plan thumbnails auto-fetch (#1136)

* feat(places): custom tooltip on the place image controls

* fix(places): show a custom place image on map markers, skip the redundant photo fetch (#1136)
2026-07-21 15:33:31 +02:00
Maurice 31f6ec1f47 feat(vacay): read-only calendar sharing (#444, #667) (#1637)
* feat(vacay): read-only calendar shares on the server (#444, #667)

* feat(vacay): shared calendars card and ring overlay on desktop

* feat(vacay): share sheet and calendar rings on mobile

* test(vacay): cover the calendar share flows

* docs(vacay): document read-only calendar sharing
2026-07-21 13:46:38 +02:00
Maurice 7528706d00 feat(vacay): half vacation days (#552) (#1631)
* feat(vacay): support half vacation days (#552)

Log a vacation day as a half day (0.5) as well as a full day and count it
as such against the entitlement. A "Half day" mode joins the calendar
toolbar on desktop and mobile; half days render as a diagonal half-fill in
the day cell, and the used/remaining totals carry the fraction.

Closes #552

* refactor(vacay): half-day as a per-person toggle with a corner badge

The diagonal half-fill collided with the two-person split — one person's
half day looked like two people off. Drop the diagonal: the cell fill keeps
showing who is off, a half day gets a small corner ½ badge, and a hover
tooltip lists who is off on that date and how much (only when a half day is
present). Logging moves from a third mode to a Half-day toggle that applies
to the selected person, on desktop and mobile.

* feat(vacay): mark half days with an orange corner dot

Swap the ½ badge for a small orange dot in the bottom-right corner, mirroring
the blue trip dot — quieter in the grid, and the hover tooltip still spells
out who is on a half day.

* fix(vacay): make the fraction migration idempotent

The crosswalk test replays the migration tail (rewind + re-run), which expects
every later migration to be idempotent. Guard the vacay_entries ADD COLUMN
behind a pragma_table_info check so the second run is a no-op. Also only set
dayVisual's `half` flag when true, so full days keep their exact
{ background, numColor } shape for the mobile day-model unit test.

* style(vacay): nudge the mobile day dots slightly inward

The blue trip dot and orange half-day dot sat right in the cell corner on
mobile; move both a touch toward the centre so they read cleaner.
2026-07-20 23:38:47 +02:00
Konstantinos Thermos a3cb783df7 fix(settings): retry settings load on reconnect so region prefs stop resetting (#1626) 2026-07-20 21:02:19 +02:00
Maurice b6a3cced43 Comprehensive mobile UI rewrite + desktop Vacay & Journey redesign (#1577)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* feat(mobile): comprehensive mobile UI rewrite

Add a dedicated phone UI (<768px) under client/src/mobile/, mounted by
MobileShell while the desktop/tablet code paths stay untouched. Each page
delegates to an M-screen that reuses the existing hooks, stores and services.

Covers dashboard, trip planner (plan timeline, map, places, tabs and sheets),
vacay, atlas, journey, collections, settings, admin and notifications, with a
shared component kit (MSheet, MBottomNav, MGlassBar, MSegmented, MToggle, …)
and design tokens in mobile.css.

Empty states use an animated TREK mascot (MDancingTrek) with per-scene props.
Adds the mobile* i18n namespaces (mobileTrip/mobileJourney/mobileVacay/
mobileAtlas/mobileNav/mobileAdmin/mobileSettings/mobileCollections) across all
22 locales.

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* feat(mobile): accommodation & save-to-collection sheets, trip loading splash

Add a dedicated mobile accommodation sheet and a save-to-collection sheet,
plus an animated trip loading splash with stepped status copy. Make the vacay
day/company-holiday toggles optimistic so cells react instantly and roll back
on error. Wire up the "subscribe to all trips" calendar feed and cover-image
add on the new-trip sheet. Sync the new dashboard/trip i18n keys across all
22 locales.

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

* feat(mobile): rebuild Settings & Admin panels natively

* feat(mobile): native booking, transport & expense form sheets

* feat(mobile): unify empty states, streamline nav, collab & collections

* feat(mobile): white atlas bucket button + custom month/year picker

* feat(mobile): show accommodation route legs in the day plan

* feat(mobile): auto-draw the day route in map mode

* feat(mobile): move the vacay invite button left of the year switcher

* fix(mobile): don't render the global dock inside the trip planner

* feat(mobile): reachable add-list, roomier packing rows & owner avatars

* fix(mobile): use the custom date picker for the to-do due date

* feat(mobile): open a running trip on today's day

* chore(client): serve the PWA manifest in dev for on-device testing

* fix(i18n): complete the Catalan (ca) locale for the mobile rewrite

* test(transport): match the shortened Manual/Automated switch labels

* test(mobile): mock the admin permissions panel MAdmin renders

* feat(vacay): rework the desktop vacation planner in the glass style

Move the calendar, sidebar cards and stats onto the --vg-* glass tokens,
soften the person-day fills, pad every month to six week-rows so the
cards line up, tighten the sidebar spacing and add You/Pending badges to
the people list.

* feat(vacay): redesign the vacation settings dialog

Widen the modal and split the options into two columns, and restyle the
holiday calendar rows, inputs and add button to match the rest of the
planner.

* feat(journey): rework the journeys dashboard in the glass style

Move the hero to a blur-mask magazine cover, put the journey cards on
glass with cover/title overlays, widen the grid to match the rest of the
app, drop the top toolbar, and add a subtitle field to the create flow.

* feat(journey): modernise the journey detail page

Centre and widen the feed+map layout, restyle the hero with a frosted
cover, glass stat pill and round controls, turn the view switch into a
glass segmented pill with a labelled add-entry button, and give the
timeline day headers, entry cards, suggestion cards and the entry
editor a rounder, glassier look.

* feat(journey): lay out the journey settings dialog in two columns

Widen the dialog and split the options into two glass cards, and round
the inputs and the save/cancel buttons.

* feat(journey): lay out the entry editor in two columns

Widen the new/edit entry dialog and split it into two columns — title,
photos and the story on the left (the story now grows to fill the
column), date/location, pros & cons, mood and weather on the right.

* feat(journey): polish the detail, settings and share UI

Move the gallery upload into the view switch as a matching pill and drop
the old button, badge the day-place and photo counts, tidy the entry
editor location field, round the settings inputs and buttons, restyle the
contributor role and share controls, and put delete next to copy.

* feat(journey): always feature a hero on the frontpage

The desktop frontpage only rendered the hero header when a journey trip was currently live, so with no live trip there was no header at all. The hero now always features one journey — the live one when there is one, otherwise the most recent — and its eyebrow reads "Latest Journey" when the featured journey is not live.

* feat(mobile): customizable bottom navbar layout

Add a Mobile section under Settings → Appearance where you arrange the bottom navbar: reorder items and choose which sit in the bar versus under the "More" popover, with a live preview. Dashboard stays pinned first. The layout persists per-user in the appearance config and drives MBottomNav; an un-customised account keeps the current Dashboard + Vacay/Atlas dock.

* fix(mobile): widen the nav customizer zone buttons to a single row

* refactor(mobile): show the nav customizer only in mobile settings

The bottom-nav layout only exists on phones, so configuring it from the desktop settings made little sense. Drop the desktop customizer (and its two now-unused labels) and keep the editor on the mobile appearance screen.

* fix(mobile): neutral tiles and no subtitle in the bottom-nav More menu

* feat(desktop): TREK mascot empty states + trip-open splash

Bring the mobile TREK mascot to desktop as a shared EmptyState (mascot + a single title, no subtitle, one uniform look) across the app empty states: reservations, transport, costs, packing, to-dos, files, collab notes/polls/chat, upcoming activities, the day plan, journey timeline and gallery, collections, atlas, dashboard and notifications. The trip planner now plays the same little mascot journey while a trip loads.

* feat(desktop): animated glassmorphism background for the trip loading splash

The trip-open splash now takes over the viewport with a calm, slowly drifting pastel gradient wash (soft pink/lavender/periwinkle, toned down), a gently breathing hue veil, a whisper of filmic grain and a soft vignette, with the mascot journey floating on a frosted liquid-glass card. Theme-aware for light and dark; the background freezes under prefers-reduced-motion.

* fix(planner): keep the empty-day itinerary as small text, no mascot

* fix(mobile): cap the bottom-nav dock at Dashboard + 2 so the More slot fits

* feat(mobile): reorderable dashboard arrangement

Add a mobile-only dashboard order control under Settings → Appearance → Mobile: reorder how the trip list and the inline widgets (currency, collections, world clocks, upcoming reservations) stack on the phone dashboard — e.g. move the currency converter above your trips. The featured trip always stays pinned on top. Stored per-user in the appearance blob (dashboard.mobileOrder); an un-arranged account keeps the current order.

* feat(plugins): map layer provider hook for the trip map

Plugins can now draw bounded vector overlays (polylines, polygons, metric
circles) on the trip map via hooks.mapLayerProvider — routes, reachable-range
corridors, zones. Same contract as map markers: declarative data only, tone
palette styling with clamped numerics, hard per-plugin budgets (4 layers /
150 features / 8000 vertices), oversized or partly-invalid shapes dropped
whole. Rendered beneath the core day route.

The GL renderer now also mounts the plugin markers it was missing — both
hooks work on Leaflet and Mapbox/MapLibre, desktop and mobile.

* feat(plugins): route provider hook with planner route profiles

A routeProvider plugin can now register routing profiles (capabilities.
routeProfiles, max 3) that show up in the planner's route toggle next to
Driving/Walking — e.g. EV routing with charging stops. Selecting one asks
exactly that plugin to route the day's stops (POST /api/plugin-routes,
20 s budget for external solvers); the result is validated whole (leg
count must match the waypoint pairs, vertex/via caps) and rendered like
an OSRM response: geometry on the map, via points as stops on the line,
per-leg times + an optional note ("25 min charge") on the connectors.
A failing provider degrades to straight lines, same as an OSRM outage.

The feed only offers profiles whose hook grant is actually recorded, and
the route endpoint re-checks provider + declared profile against the DB
row, so a hand-edited capabilities blob can't invent routable profiles.

* feat(plugins): day schedule contributions in the planner

A dayScheduleProvider plugin can attach time entries to the day plan —
"35 min charging at this stop", "45 min security before this flight".
Rows are host-rendered under their anchor (a place/booking row, or a
day's start/end) on both the desktop sidebar and the mobile timeline,
and the contributed minutes are folded into the day's route-footer
total as "+X min".

Because this output feeds displayed timing, the server clamps minutes
to a day, checks every dayId against the trip's own days and budgets
the item count per provider; labels go through the usual sanitize+cap
pipeline.

* feat(plugins): geolocation bridge permission

New geolocation:read permission — the first bridge-level grant: a plugin's
sandboxed frames can ask the HOST for the browser position over postMessage
(window.trek.geolocation.get()/watch()). The sandbox itself never gains the
geolocation API; the host page reads navigator.geolocation, so the browser's
own site permission prompt still applies on top of the admin consent, and
the position only travels parent -> frame, never to the server.

The feed flags granted plugins, an ungranted frame gets a 'forbidden'
answer without touching the browser API, one GPS watch per frame, and the
watch is force-stopped when the frame closes. The dev preview answers with
a fixed position so trek.geolocation works without a prompt.

* fix(mobile): render trip-page plugin tabs

The mobile trip shell listed plugin tabs in the Mehr sheet and let the user
open them, but MTripTabPanel had no plugin: branch — the tab came up as an
empty scroller. Mount the same sandboxed PluginFrame the desktop planner
uses, filling the panel above the dock.

* test(plugins): add granted_permissions to the feed fixture schema

The plugins feed now selects granted_permissions (route profiles + the
geolocation flag are gated on the recorded grant), so the hand-written
in-memory schema this suite shares needs the column.

* fix(map): don't crash the trip map when the pane API is absent

PluginMapLayers called map.getPane/createPane unconditionally, which threw
'map.getPane is not a function' on a minimal Leaflet map (breaking MapView
in tests and any renderer state without pane support). Pane placement is a
z-index optimization — guard it and fall back to drawing the plugin
features without the dedicated pane when it isn't available.

* harden(plugins): bound normalize work, scope route cache, enforce geo grant live

Adversarial audit of the four new hooks found no trust-boundary defect; these
close the low-severity gaps it confirmed:

- map-layers / day-schedule / plugin-routes: slice the raw provider array before
  iterating, so an all-invalid oversized payload can't force an unbounded scan on
  the host event loop (the output was already capped; this bounds the work too).
- RouteCalculator: a plugin route is trip-/day-specific, so its cache key now
  includes tripId/dayId — two days with identical coordinates no longer share one
  plugin route (its charging stops/notes differ per day).
- PluginFrame: a live geolocation watch re-checks the grant on every fix and stops
  at once if an admin revokes geolocation:read while the frame stays open.

* test(client): update stale assertions to the mobile-rewrite UI

The mobile rewrite moved empty states to the shared EmptyState mascot
component (title only, no subtitle) and replaced the loading gif with
TripLoadingSplash, but several tests still asserted the old copy/markup:

- CollabChat / PackingListPanel / ReservationsPanel / AtlasPage /
  JourneyDetailPage: assert the current empty-state (title + scene mascot)
  instead of the removed hint paragraphs.
- TripPlannerPage: mock TripLoadingSplash (its mascot-cycling setInterval
  never drains under fake timers, aborting 14 splash-gated tests) and
  assert the current loading node.

Test-only — no component behavior changed.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
2026-07-20 20:44:12 +02:00
Konstantinos Thermos 229ec6d18e fix(notifications): RFC 2047-encode ntfy header umlauts (#1621) 2026-07-20 13:32:55 +02:00
Fabi 0b4737448d Clarify terminology for car rentals in documentation (#1617)
* Clarify terminology for car rentals in documentation

Changed wording for Car / Car-Rentals

* Clarify car rental terminology in transport section
2026-07-20 11:17:51 +02:00
Maurice adbee5aa13 ci: use trek-release app token for version-bump push, annotate release tag 2026-07-19 20:52:43 +02:00
github-actions[bot] a099465889 chore: bump version to 3.4.1 [skip ci] 2026-07-19 17:30:21 +00:00
jubnl 91095ef96a v3.4.1 (#1606)
* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz

* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

* fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602)

The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'.
The modal mounts closed, so the first open render executed one more hook
than the previous render and React unmounted the whole tree (error #310),
blanking the page. Move the memo above the early return and cover the
closed-then-open transition with a regression test.

* fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607)

On an accommodation's check-in day the hotel -> first-stop leg was drawn
by default for any PLACE first stop, suppressed only when that place was
explicitly timed before check-in. An un-timed first place (e.g. "Home"
on day 1 of a driving holiday) therefore always produced a phantom
hotel -> Home leg, regardless of the check-in time.

Flip the default to mirror shouldDrawEveningLeg: the morning leg is now
drawn only when the first place is provably timed at/after check-in
(you dropped your bags first). The drawn map route, the sidebar hotel
connectors, and the Google Maps export all share this helper and
inherit the fix. The optimizer anchors (#1321) are unchanged.

* fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608)

* chore: correct shields.io url

* chore: correct shields.io url

* chore: update helm repo link

* chore: document new helm chart url

* chore: document new helm chart url

* fix(atlas): make Kosovo selectable on the Atlas map (#1609)

Kosovo's user-assigned ISO code (XK/XKX) was missing from both code
tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the
shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3
map. With neither resolvable, onEachFeature attached no hover/click
handlers to Kosovo's polygon and the country search dropped it, making
Kosovo impossible to select.

- add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map
- stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle
- guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003)

* chore: normalize docker image references to mauriceboe/trek (lowercase)

* fix(memories): honor Synology skip-SSL on photo streaming (#1611)

The synology_skip_ssl setting was forwarded on the JSON API path
(login/browse/test-connection) but not on the image-byte fetches, so a
NAS with a self-signed certificate passed Test Connection while
/api/photos/:id/thumbnail and /original failed with 500
"Failed to fetch asset" — and the TLS error was silently swallowed.

- pipeAsset: accept fetchOptions and forward to safeFetch; log the
  underlying error on the 500 path (without the URL, which carries _sid)
- fetchSynologyThumbnailBytes / streamSynologyAsset: pass
  rejectUnauthorized derived from synology_skip_ssl; log on failure
- i18n: replace hardcoded "Immich" with {provider_name} in 7 memories
  keys across 14 locales (source of the reported "Failed to connect to
  Immich" banner on the Synology test), fix translated placeholder
  names in tr/ca/pl, and add a placeholder-parity spec to prevent
  regressions
- client: gallery provider badge matched 'synology' instead of the real
  id 'synologyphotos', showing the raw id; label is now
  "Synology Photos"

* chore: make chart.liketrek.com the canonical helm chart url

* feat(plugins): prefer the registry's resolved store screenshot (#1613)

Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png.

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
2026-07-19 19:00:40 +02:00
jubnl 3ca1ef34bb fix: egress policy 2026-07-18 23:00:04 +02:00
jubnl e6c3dc3e46 bump sdk 2026-07-18 22:52:28 +02:00
github-actions[bot] 55fa668dd2 chore: bump version to 3.4.0 [skip ci] 2026-07-18 20:51:16 +00:00
jubnl f1b585727a fix: build 2026-07-18 22:49:56 +02:00
jubnl 9de75b29ca Revert "fix(ci): lowercase docker image name, make release re-runnable, push version tag"
This reverts commit 5134fd9459.
2026-07-18 22:47:43 +02:00
Maurice 5134fd9459 fix(ci): lowercase docker image name, make release re-runnable, push version tag 2026-07-18 22:44:31 +02:00
github-actions[bot] 3e47622b00 chore: bump version to 3.4.0 [skip ci] 2026-07-18 20:28:02 +00:00
jubnl a2bd9be184 v3.4.0 (#1540)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
2026-07-18 22:13:52 +02:00
jubnl f9c992ec93 bump sdk 2026-07-11 22:33:55 +02:00
github-actions[bot] afea106aed chore: bump version to 3.3.0 [skip ci] 2026-07-11 20:31:37 +00:00
github-actions[bot] 24e3c5891a chore: bump version to 3.2.2 [skip ci] 2026-07-11 20:29:08 +00:00
Maurice 19064b3917 3.3.0 (#1520)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
2026-07-11 22:28:32 +02:00
jubnl 3db2495bcd fix(sdk): bump version 2026-07-06 02:26:41 +02:00
jubnl 43d30245a0 fix(sdk): add supported plugin type in preflight 2026-07-06 02:25:40 +02:00
jubnl f4d1c0baa4 chore(plugin-sdk): release v1.3.0 2026-07-06 00:34:32 +02:00
github-actions[bot] ada18dd70d chore: bump version to 3.2.1 [skip ci] 2026-07-05 22:28:08 +00:00
jubnl 91025683bb 3.2.1 (#1433)
* fix(plugins): prevent arbitrary api access

* fix(planner): disable drag & drop on mobile so the places list scrolls (#1432)

On touch devices the draggable rows hijack the scroll gesture, so dragging
to scroll started an HTML5 drag and popped up the file-import overlay instead
of scrolling. Gate the draggable rows and the sidebar file-drop handlers on
!isMobile across the places sidebar and the day plan (places, transports,
notes), and hide the grip handle — the arrow reorder buttons take over there.

* fix(inspector): make the remove-from-day button icon-only on mobile

* fix(collections): show the save picker above the mobile place detail

* fix(plugins): seal IPC parent/child for good

* test(inspector): match the icon-only remove-from-day button

* feat(sdk): switch plain ts for clack/prompts interactive session

* feat(plugins): force-refresh the registry from the rescan button

The registry is cached for 30 min server-side and GitHub serves it with a
5-min CDN cache, so a freshly published plugin could take up to ~35 min to
appear. The rescan/reload button now force-pulls the registry: it bypasses the
in-memory cache and appends a cache-buster + no-cache headers to beat the CDN,
and refreshes the browse grid immediately.

* feat(sdk): bump plugin version

* fix(stored settings): prevent local storage drop when update not successful

* feat(plugins): sideload plugins by uploading a .zip

Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for
installing a plugin archive directly — handy for testing a build before it goes
to the registry. It reuses the registry install pipeline (slip/bomb-safe
extract, strict manifest validation, native-binary scan) via a new
POST /admin/plugins/upload, and only skips the registry sha256/signature checks
that a sideload can't have.

Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no
GitHub link, no auto-update) and always land INACTIVE — replacing a running or
active plugin stops it and clears the active flag first, so new code never runs
without a fresh activation + permission consent.

* fix(planner): keep the day-plan collapse state after fully closing the page

The expanded/collapsed days were stored in sessionStorage, which survives a
reload but is wiped when the tab or window is closed — so every fresh open
re-expanded all days, which is tedious to re-collapse on long (10+ day) trips.
Store it in localStorage instead so a collapsed layout sticks until it's
changed.

* fix(i18n): correct Vietnamese translation of 'Disabled' (#1438)

'Tàn tật' means physically handicapped/disabled-person, not the
off/disabled state of a toggle. Replace with 'Tắt' (off), matching
the existing 'admin.plugins.stateOff' translation.

Affects admin.notifications.none and admin.addons.disabled.

* add the code of conduct

* fix(plugins): let widgets follow the in-app dark-mode toggle

The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we
only sent the context — including the theme — once, on trek:ready. So toggling
dark mode in TREK left already-mounted widgets on the old theme until a reload.
Watch the <html> `dark` class and re-post the context when it flips; plugins
already re-apply the theme on trek:context.

* fix(plugins): deliver widget context on load so the theme is right on first paint

* fix(plugins): give widget cards the native glassy look and auto-height

Widget plugins rendered in a plain card with a fixed 180px body, so they looked
foreign next to the glassy dashboard tools and taller widgets had their controls
clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase
title) and let the body grow to the height the widget reports over trek:resize.

* feat(plugins): add read/rwite costs

* feat(plugin): better readme/index.js

* feat(plugin): better readme/index.js

* feat(plugins): hand widgets TREK's theme tokens, formats and display identity

Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design
tokens for the current theme), `formats` (currency/date/units/timezone) and a
`user` display object (name/avatar/isAdmin — never the email, role only as a
boolean). Re-sent on every theme toggle. A widget can now apply the tokens and
match the host exactly, in both themes and under a custom appearance, instead of
hard-coding a palette that drifts — so plugins feel native, not bolted-on.

* feat(plugins): hand plugins the full palette and appearance state

The theme context only carried a ~19-token subset read off <html> and only
followed the dark-mode toggle. Widen it to the whole global (:root/.dark)
palette — surfaces, text, borders, the accent family, semantic + soft fills,
shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme,
custom accent and high-contrast live, not just light/dark. Also send an
`appearance` block (scheme, density, reduced-motion, no-transparency) mirrored
from the attributes applyAppearance writes on <html>, and re-post the context
whenever any of those actually change (a small signature dedupes unrelated
mutations) so plugins restyle in step with the app.

* feat(plugins): ship a design kit so plugin UIs look native

A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's
stylesheet — so authors had to re-derive the whole look by hand, and most
didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons,
inputs, chips, rows) that consumes the tokens the host already sends and swaps
light/dark, plus a small bootstrap that applies those tokens, mirrors the
appearance flags, auto-reports the frame height and exposes a `window.trek`
helper over the existing bridge. Both are plain strings meant to be inlined
(the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a
`<!-- trek:ui -->` marker. No new capability — only a native look.

* feat(plugins): deliver the design kit — native scaffold + inline on dev/pack

A new page/widget scaffolds a native, glassy starter that talks over
window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it
serves /ui) and `pack` (as the file enters the archive) expand it into the
inlined kit — so the file stays a one-line opt-in and a rebuild always ships
the current kit. Existing plugins opt in the same way, by dropping the marker.

* feat(plugins): faithful themed host preview in dev

`dev` served the plugin UI raw at /ui — top-level, with no host — so the theme,
context and bridge never fired and authors couldn't see the design kit render.
Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's
isolation) and plays the host — posts trek:context with a theme/accent/appearance
toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces
resize/notify/navigate. /ui stays as the raw doc for debugging.

* docs(plugins): document the design kit, window.trek and the token contract

Rewrite the client section of the Plugin Development wiki kit-first: the
`<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the
`/preview` host preview, the full `trek:context` payload (now the whole palette
plus an `appearance` block) and how to apply tokens by hand. Add a "Build a
native UI" section + the new exports to the SDK README.

* feat(budget): add 'Outstanding amount' card

* fix(translations): finish translating new keys

* feat(plugins): trip-page plugins — a plugin tab inside every trip

Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the
trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with
no dashboard nav entry. This is the most-asked planner-extension request from
discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget
planner). It reuses PluginFrame and the existing tab system — the frame already
receives the current tripId over trek:context — so there is no bridge or
security change: only the manifest type enum (server + SDK), the client feed
classification (pluginStore.tripPages), and one render branch in the planner.
The SDK scaffolds it with `create --type trip-page`.

* fix(apple wallet): support for .pkpasses

* feat(plugins): permission-gated write APIs for the planner (#1429)

Plugins can now WRITE core planner data, not just read it, through curated,
membership-checked methods — so downstream features can live in plugins instead
of long-lived core patches. Four new scopes: db:write:places (create/update/delete
places), db:write:days (days), db:write:itinerary (assign/unassign a place on a
day) and db:write:trips (update trip fields).

Each ctx method mirrors costs.create: it validates the input against the SAME
@trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad
has none, so its writes are refused), checks trip access AND the app's edit
permission (place_edit / day_edit / trip_edit), delegates to the real services,
broadcasts the same events so open sessions update live, and records the write in
the tamper-evident capability audit. No new route, no sandbox or CSP change — the
isolation boundary is unchanged; a plugin can only change what its user could change
by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host,
and the wikis are updated.

* docs(plugin): ensure wiki correctness

* feat(plugins): plugin metadata on core entities — db:meta (#1429)

Plugins can now attach their OWN namespaced key/value data to a trip, place or
day without forking the core schema (#1429, request 2). New `db:meta` scope +
`ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table
(migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only
ever sees its own rows. Every call is membership-checked: the entity must belong to
a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB
per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and
recorded in the capability audit. SDK types + mock host, a consent chip + labels in
all 22 locales, and the wikis. No new route, no sandbox change.

* feat(plugins): place-detail plugin slot in the trip planner (#1429)

A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount
its sandboxed frame inside the trip planner's place-detail panel, scoped to the
open place — the frame receives the `placeId` in trek:context alongside the tripId.
This is the UI half of the place-detail-providers ask (reviews/ratings/popular
times shown on a place). It reuses the existing widget mechanism: PluginFrame gains
an optional placeId, the feed/store learn the new slot, and PlaceInspector renders
the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales,
wiki updated. No sandbox or permission change.

* fix(plugins): green the server tests + harden the new capability surface

The in-memory uninstall fixture was missing the new plugin_entity_metadata table,
so uninstall's DELETE threw "no such table" and failed the server test job. Add the
table to the fixture schema.

Self-review hardening of the write/metadata surface:
- trips.update now reproduces the web UI's per-field gate: is_archived needs
  trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a
  member who may only edit can't archive or re-cover a trip.
- Plugin metadata WRITES now also require the entity's edit permission
  (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't
  overwrite or delete metadata another user created. Reads stay access-gated.
- Cap the metadata key length (<=256 chars) alongside the value/count quotas — the
  key was attacker-controlled and uncapped, defeating the disk-DoS guard.

* test(plugins): cover the new write/metadata deps to hold the coverage gate

The new create-rpc-host write + metadata deps were untested, dropping the
src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus
mocked core services to exercise every dep end-to-end: places/days/itinerary
create/update/delete + not-found paths, trips.update with the archive/cover
per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD
+ key/value/count caps + access checks, the costs deps, and users.getById
scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user
refusal. Tests only — no production code change.

* fix(costs): freeze FX on every cost + settlement write path (#1445)

Settled foreign-currency costs kept re-opening with a few-cent residual
when live rates drifted. The #1335 freeze only ran on the REST create/
update path, so two gaps remained:

- Foreign-currency items created via MCP create_budget_item or booking-
  import bypassed the freeze and stored exchange_rate = 1, so settlement
  re-converted them with live rates. Promote freezeForeignRate into the
  shared budgetService and call it from every write path.
- Settle-up transfers were stored currency-less and re-converted with
  live rates on each recompute. Add currency + exchange_rate to
  budget_settlements (migration), freeze the display-currency rate at
  settle time, and convert with it in calculateSettlement. Legacy rows
  (currency = NULL / rate = 1) keep live-rate behaviour until re-edited.

Also expose guarded cost update/delete to plugins: costs.update and
costs.delete under db:write:costs, gated exactly like costs.create
(addon + trip access + the acting user's budget_edit permission).
updateCost reuses BudgetService.update so a plugin write re-freezes the
FX rate too; both broadcast the same budget:updated / budget:deleted
events the REST controller emits. Wired through the host, the runtime
SDK context and the published trek-plugin-sdk (types + mock host).

* feat(plugins): provider hooks — placeDetailProvider, wired (#1429)

Turn "hooks" from a declared-but-dead surface into a real host→plugin capability.
Add an invoke.hook branch to the child + a supervisor hook registry
(providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke
transport and its timeout (a short 5s deadline so a slow provider can't delay a
response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the
in-repo runtime SDK copy was missing the `hooks` field entirely and could not even
parse a plugin that declared one — synced it with the published SDK.

The first wired hook is placeDetailProvider: a plugin returns extra rows
({label,value?,url?}) for a place, and TREK renders them natively at the foot of the
place-detail panel. Consumer is a new, additive, fail-safe endpoint
GET /api/place-details/:placeId (membership-checked; any provider that errors or
times out is simply skipped — it never breaks the panel). New hook:place-detail-
provider scope + consent chip in all 22 locales. SDK types (both copies), a
controller test, and the wiki. photoProvider/calendarSource stay reserved but the
transport now exists for them. No sandbox or CSP change.

* fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448)

Both bugs were client-only; the server already allows .pkpass and serves it
as application/vnd.apple.pkpass.

#1448: the reservation/transport attachment inputs hard-coded an accept list
that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet
MIME types) to the accept attribute in both modals.

#1447: the files-tab open path routed every non-media/non-markdown file into
the in-app PDF preview object. Add isWalletPass() and route wallet passes
through the shared blob openFile helper (as bookings already do), which
downloads them so the OS hands them to Apple Wallet.

* feat(plugins): validation/warning contributions via warningProvider hook (#1429)

Second wired provider hook, reusing the invoke.hook infra from the last commit. A
plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message,
dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its
planned date, missing booking, …). TREK surfaces them as a non-blocking overlay
banner at the top of the trip planner (the wrapper ignores pointer events so it
never covers the map/panels; only the pills are interactive).

Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId
(membership-checked; a provider that errors or times out contributes nothing and
never blocks the planner). New hook:trip-warning-provider scope + consent chip in
all 22 locales, SDK types (both copies), a controller test, and the wiki. This is
the validation half of the scheduling+validation block; feeding durations/travel
times back into core recalculation stays out (it would touch core planner
computation — deliberately deferred to keep the no-breaking-changes guarantee).

* fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit)

The adversarial audit of the #1429 additions found one real (medium) gap: the
hook:* permission was never enforced at runtime. providersOf() selected provider
plugins purely by the hooks their CODE declares (sup.hooks, reported by the child
as Object.keys(def.hooks)) and never intersected that with sup.granted — so a
plugin that merely implemented placeDetailProvider/warningProvider got wired in as
a provider even when the admin never consented to hook:place-detail-provider /
hook:trip-warning-provider. The downstream capability router still held (the hook's
ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an
auto-triggered, user-bound execution context on a passive UI browse without the
hook being consented — a consent-integrity gap that contradicts the documented
invariant.

Gate it host-side: a hookName→permission map, and providersOf now returns a plugin
only if it is active, implements the hook, AND holds the matching hook:* grant. An
unmapped hook resolves to nobody. invokeHook additionally re-checks membership in
providersOf (defense-in-depth against a direct caller). Unit test proves the
grant/implements/active intersection.

* docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco)

Fosters plugin authoring by turning the new #1429 capabilities into copy-paste
recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an
entity with metadata, contribute native place details, raise trip warnings,
broadcast, match the TREK look) linked in the sidebar, plus a complete runnable
example — trip-doctor — a hooks-only plugin that showcases warningProvider +
placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against
the SDK. Docs/example only; no product code.

* fix(collections): don't reset saved-place status to 'idea' on edit (#1437)

The update schema reused collectionStatusSchema, whose .default('idea')
survives .optional() — so a PATCH that omits status had 'idea' injected by
the validation pipe and written to the DB, clobbering 'want'/'visited'.
Strip the default on the update field with .removeDefault(), keeping the
.catch guard. Add a shared schema regression test and an e2e round-trip.

* docs(plugin): ensure plugin scopes are the same everywhere

* feat(plugins): read scopes for packing + files (#1429 eco)

Extend the read side of the capability model beyond trips/costs: db:read:packing
→ ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror
the existing trip reads exactly — the host membership-checks the trip against the
invocation's user (tripRead) before delegating to the same packingService/
fileService the REST paths use (so bags/assignees hydrate and trash is excluded),
and each is a separate scope (packing doesn't unlock files). ctx types in both SDK
copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host +
create-rpc-host tests, and the wiki (perm table + cookbook recipe).

* feat(plugins): core event subscriptions (#1429 eco)

A plugin can react to core activity by declaring events: [{ on, handler }] + the
events:subscribe grant. websocket.broadcast announces every CORE trip event (name +
tripId ONLY, never the payload) through a tiny dependency-free relay
(plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor
fans each event out to subscribed, granted, active plugins via a fire-and-forget
invoke.event on a short timeout — so a slow subscriber can never block a core write.

Safety by construction: handlers run with NO user (like a job) so trip reads are
refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the
grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re-
broadcasts are never delivered back, so handlers can't loop; and only the event name
+ tripId cross the boundary. SDK types (both copies), consent label + cap chip in all
22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook.

The relay lives in its own module (not websocket) so it doesn't drag `ws` into the
runtime and tests that mock ./websocket don't strip the sink.

* feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco)

Two author-DX wins, SDK-only.

Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list,
files.list, costs.*, places/days/itinerary writes and users.getById now return
proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile,
BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors.
Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the
raw DB row honestly (no column hidden, no false guarantees). mock-host matches.

Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM
builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a
widget builds themed UI with no CSS and no build step. Ships inlined via the same
<!-- trek:ui --> marker. Wiki updated.

* fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit)

The final eco audit found one real (medium) gap: the db:read:packing delegate
called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED
branch and returns every member's private (is_private=1) packing items — leaking
another member's personal/surprise-gift items to a plugin the normal UI/REST hides
them from. The handler had the host-bound acting user but dropped it when delegating.

Thread it through: tripRead now hands the membership-checked userId to the read
callback, packing.list forwards it to listPackingItems(tripId, userId), and the
service applies its three-tier #858 filter — a plugin now sees exactly what its user
sees. files.list is unaffected (no per-user file visibility). Tests assert the user
is passed. The other three audited surfaces (event subscriptions, trek.ui, and the
regression sweep of the capability boundary) were clean.

* security(plugins): prevent open redirect

* fix(plugins): resolve PR #1433 full-audit findings (code + tests)

The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own:

- ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser
  never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified
  the bug — corrected).
- Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app
  rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000).
- packing.list / files.list were missing from the capability audit log while every
  other core read is audited — add them to isAuditable + auditResource.
- SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string);
  the host->plugin boundary is JSON, so align both to string.
- Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing
  filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label.
- Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event
  dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's
  defense-in-depth grant re-check.

Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched).

* docs(plugins): correct the wiki against the shipped capability surface (#1433 audit)

Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked
against the code:

- Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not
  egress[]; dropped the stale "costs.create is the first and only core mutation";
  documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table
  gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta,
  hook:trip-warning-provider); the widget slot table gained place-detail.
- Plugin-Cookbook: days.create no longer passes a title the schema drops;
  broadcastToUser uses the real (userId, event, data) signature; fixed the broken
  #the-trek-ui-design-kit anchor and noted window.trek.ui.
- Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe;
  the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx.

* fix(unsplash) allow api key usage

* fix(guests): scope guest display names per-trip, not globally (#1446)

A guest is a per-trip person, but their name lived in the globally UNIQUE
users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other
trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human
name there and gets a uuid-based username that is never shown, and every member view
(members list, day-assignment participants, budget members/payers, packing
recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename
updates display_name with no dedup. Real users are unchanged (display_name NULL →
COALESCE falls through to username). Migration adds the nullable column; existing
guests keep their current username via the COALESCE fallback.

This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects
display_name. Tests: two "Jake" guests on two trips both keep the name; the two
codified-the-old-behaviour guest tests corrected.

* fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445)

The full audit found that updateSettlement called freezeForeignRate without the
"currency unchanged" guard the item path has, so any edit of a foreign-currency
settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the
frozen one — re-opening an already-balanced position with a small residual, the
exact drift #1445 was meant to prevent.

freezeForeignRate's unchanged-check was item-centric (it queried budget_items),
which a settlement (a different table) can't use. Give it an explicit
existingCurrency param; updateSettlement now reads the settlement's stored currency
and passes it, so an edit that doesn't change the currency keeps the frozen rate
(the service UPDATE already preserves exchange_rate when it's left unset). Tests
cover both: unchanged currency keeps the rate, a real currency change re-freezes.

* feat(plugins): add inter plugin dependency support and addon dependency support

* feat(plugins): add inter plugin dependency support and addon dependency support

* docs(plugins) inter dependencies

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
2026-07-06 00:27:42 +02:00
github-actions[bot] b26be30a25 chore: bump version to 3.2.0 [skip ci] 2026-07-04 23:17:14 +00:00
github-actions[bot] 7dc921dff3 chore: bump version to 3.1.5 [skip ci] 2026-07-04 23:13:44 +00:00
Maurice 7eabf6066f 3.2.0 (#1426)
* docs(wiki): document the snap Docker + no-new-privileges startup failure

* fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin

The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.

* feat(extract): extract data using LLM

* fix(extract): auto-run the AI fallback when the addon is enabled

Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.

* fix(extract): make AI imports reliable and fast on local models

client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.

* feat(extract): fill transport/booking fields, geocode endpoints, assign days

- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.

* perf(extract): cap LLM input at 4000 chars for CPU-only speed

On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.

* feat(extract): capture seat, class, platform, price + event venue contact

Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.

* feat(extract): create a linked cost from the booking price on import

When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.

* fix(extract): refresh accommodations after a booking import

A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.

* feat(extract): drive NuExtract with its native template

NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.

* fix(admin): tidy the AI parsing settings and recommend the 2B model

The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.

* feat(import): review each parsed booking before it's saved

Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.

* feat(extract): drive local parsing through a layered extraction router

The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.

* feat(import): parse bookings in the background with a progress widget

Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.

* fix(import): create linked costs and accommodations from reviewed bookings

Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.

* feat(extract): add Expedia and rental-broker booking templates

Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.

* fix(extract): backfill booking code/total and harden the reference match

Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.

* fix(import): keep the parse-progress widget across a reload

Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.

* fix(reservations): skip un-geocoded endpoints instead of failing the save

reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.

* fix(import): resolve an imported transport's day from its parsed dates

A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.

* fix(import): refresh costs after a booking review so imported expenses appear without a reload

Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.

* refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers

Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.

* feat(import): attach the parsed source document to each booking

Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.

* fix(extract): disable model thinking for grammar-constrained extraction

Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.

* feat(extract): recommend Qwen3-8B as the local extraction model

A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.

* refactor(extract): drop vendor templates, let the model drive with deterministic backfill

Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.

* fix(extract): require the hotel address and ask for the rental company

After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.

* fix(import): refresh costs immediately after an imported booking is saved

The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.

* perf(extract): cap single-booking text tighter; require rental company

A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.

* fix(import): preview the parsed cost as linked in the review modal

During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.

* fix(import): persist source files in IndexedDB so attach survives a reload

The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.

* chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)

Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.

* feat(settings): let users set their own AI parsing model

Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.

* fix(settings): show the Integrations tab when only AI parsing is enabled

hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.

* feat(settings): use the shared custom dropdown for the AI parsing provider

Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).

* refactor(planner): move the import-review bridge effect into the page hook

TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.

* test(llm-parse): cover the extraction router, client factory and import jobs

The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).

* test(setup): stub websocket addListener/removeListener in the global mock

BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)

* fix(i18n): add Swedish translations for the AI booking-import settings

The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.

* fix(extract): don't let the day-clamp fallback break reservation resync (#1288)

This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).

Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).

* Added focus to search places in placeFormModal

* fix(airtrail): import departure/arrival times for manually-entered flights (#1336)

The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.

AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).

Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).

* fix(pwa): stop unregistering the service worker on offline boot (#1346)

Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.

Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.

Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.

Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).

* fix(map): keep the mobile GPS button above the day-detail panel (#1348)

On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.

DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:

  hasDayDetail
    ? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
    : calc(var(--bottom-nav-h) + 12px)

Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.

Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).

* feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)

On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.

Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.

Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).

Implements mauriceboe/TREK#1349

* [+] Unsplash

* [+] i18n

* feat(trips): download chosen Unsplash covers into uploads (#1277)

Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.

* test(trips): cover the Unsplash cover download and search-race guard (#1277)

Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.

* fix(docker): keep server/reset-admin.js in the build context (#1339)

The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.

* fix(llm): stop the browser autofilling the LLM base URL (#1301)

The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.

* feat(appearance): add per-user appearance config contract

Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.

* feat(appearance): token-driven theme engine with schemes and FOUC-safe boot

applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.

* feat(settings): appearance settings tab

New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.

* feat(dashboard): per-device widget visibility with layout reflow

Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.

* chore(appearance): add theme:lint guard for hardcoded styles

A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.

* fix(appearance): scale inline px font sizes so text-size reaches all content

The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.

* fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off

Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.

* fix(appearance): keep i18n key parity and update the scaled-emoji test

Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.

* feat(appearance): granular per-size text scaling with live preview

The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.

* feat(appearance): show per-size text controls inline with examples

The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).

* fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile

* fix(appearance): make the dashboard hero boarding-pass solid with transparency off

* feat(appearance): mark the Readability section as experimental

Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.

* chore(about): remove the monthly supporters section

* refactor(settings): rename the Display tab to General and group its settings

The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.

* refactor(admin): group the admin sidebar tabs into sections

The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.

* feat(help): embed the TREK wiki as an in-app help centre

Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.

* feat(auth): explain the plain-HTTP secure-cookie gotcha on login

When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.

* feat(costs): Splitwise-like cost splitting

Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.

* feat(i18n): add Vietnamese translations

* chore(i18n): sync Vietnamese with latest dev keys

Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.

* feat(helm): Add existingClaim variable for custom PVC usage.

* fix(helm): emptyDir is used as a fallback when persistence is disabled.

* docs(helm): clean up existingClaim notes

Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.

* feat(feeds): subscribable ICS calendar feeds for trips

Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden calendar feeds: absolute URLs, real disable, folding, schema sync

- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.

* feat(mcp): add bulk_update_places tool

Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.

* feat(dashboard): show the year on trip dates from other years

Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").

* feat(places): bulk "change category" from the selection toolbar

Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.

* feat(map): include the day's route in the map fit (#1128)

Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.

* feat(offline): detect update conflicts on the server for places and packing

Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.

* feat(offline): force-offline mode, selective sync and a conflict queue

A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.

* feat(offline): Settings -> Offline controls and a status banner

The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.

* i18n(offline): offline settings strings across all locales

* docs(wiki): document force-offline, selective storage and conflicts

* feat(video): media_type discriminator + local gallery video upload (server)

trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.

* feat(video): play local gallery videos in the journey gallery

Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.

* feat(video): use Plyr for the gallery video player

Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.

* feat(video): link and stream Immich videos in the journey gallery

Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.

* test(photos): assert the forwarded Range arg on the original stream

Follow-up to the Range-aware photo proxy.

* feat(video): upload and play videos in the trip file manager

The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.

* fix(video): harden upload handling and fix video playback edge cases

Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.

* test(video): update gallery accept selector + complete fileService mocks

The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.

* test(video): cover the new upload-handler branches

Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.

* feat(bookings): add a dedicated URL field to reservations (#935)

Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.

* feat(files): render uploaded Markdown files inline (#1345)

Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.

* feat(lists): reorder packing/to-do lists and private packing items (#969, #858)

Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.

* feat(trips): transfer trip ownership to a member (#973)

Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.

* i18n: translate the booking link field across all locales (#935)

Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.

* fix(packing): drop the always-true guard in the row drag handler (#969)

The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.

* feat(trips): guest members for accountless participants (#1362, #1291)

Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.

* feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)

Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.

* style(packing): small gap between the list and the luggage sidebar divider

The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.

* feat(map): group GL place markers into clusters on zoom-out (#1385)

MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.

* fix(map): match the GL place hover tooltip to the Leaflet map (#1385)

The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.

* feat(collections): backend for the Overall Places addon (#1081)

Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.

* feat(collections): /collections page, entry points and i18n (#1081)

Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
  addon: a multi-list rail, a Grid (default) / List / Map view switch, the
  idea/want/visited status with a one-tap badge, search and status filters, and
  considered empty states. Store + hook + model + websocket wiring; the place
  detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
  place inspector (and the two sidebar context menus), a "Copy to trip" modal,
  and a desktop-only two-column add-place picker (mobile keeps the single-column
  form).
- The collection namespace and the new keys across all 22 locales.

* feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)

- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).

* feat(collections): redesign the page on the dashboard glass language (#1081)

Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.

* feat(collections): list+map split, taller rail, list-menu popover fix (#1081)

- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.

* feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)

- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.

* feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)

Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.

* fix(collections): review follow-ups on the B–E work (#1081)

- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.

* fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)

- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.

* feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)

- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
  status segment → description → links) with a proper footer action bar — the
  loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
  and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
  same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
  an inset ring and only clip during the map-collapse animation; a picked row
  now scrolls into view above the detail sheet.
- New category strings across all 22 locales.

* feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)

- Map: markers no longer rebuild on every unrelated re-render (memoised the
  mappable list + only update the hero size when it really changes), the floating
  controls bar is click-through except its buttons, and the collection map runs
  with the hover tooltip off. Together these stop a marker click from landing on
  a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
  above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
  category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
  in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
  instead of their own line.

* feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)

- Editing a place (category, status, …) no longer reloads the view: the mutating
  client's own socket is now excluded from the WS broadcast (x-socket-id threaded
  through save/update/status/delete + list update/cover), so the optimistic update
  stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
  place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
  button beside it; editing/deleting a list now happens there. The list rail drops
  its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.

* fix(collections): copy-to-trip labels + Unsplash cover search (#1081)

- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.

* fix(collections): stop the address pin from shrinking on long addresses (#1081)

The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.

* fix(collections): white screen when editing a place (undefined in places) (#1081)

updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.

- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
  a stray undefined entry so a single bad row can never white-screen the page.

* feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)

- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.

* style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)

The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.

* fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)

- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
  panel instead of stopping half-way, and add list + status filter dropdowns
  (filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
  lists from the store, but on first load those aren't fetched yet (loadAll still
  running). Load them first when empty so the union isn't blank.

* test(collections): unit-test the nest controller (branch coverage) (#1081)

The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.

* fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)

From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
  drops the growing spacer) and clears the home indicator (safe-area padding,
  84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
  buttons, detail close/actions, drawer rail rows, and the interactive status badge
  (enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
  stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
  filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
  status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.

* feat(collections): per-member permission roles on shared lists (#1081)

The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.

* feat(collections): bulk-add selected trip places to a list (#1081)

Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.

* style(collections): custom dropdown for the permission role pickers (#1081)

Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.

* style(collections): widen the share modal (#1081)

* test(collections): client component tests + select in All saved, off the map (#1081)

- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).

* docs(wiki): add Collections addon page (#1081)

New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.

* feat(date-picker): add month/year drill-down navigation and keyboard input trigger

- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger

* fix(date-picker): locale-aware keyboard input parsing and i18n control labels

- Replace fixed-order date parser with locale-aware implementation
  using Intl.DateTimeFormat.formatToParts to detect field order;
  adds swap fallback for unambiguous inputs (day > 12) to handle
  locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
  (e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
  calls; add new keys under common.datepicker.* namespace across
  all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
  to avoid locale-dependent test failures

* fix(date-picker): add missing locale file and fix let-to-const lint error

- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
  to satisfy lint rules

* chore(i18n): backfill datepicker keys for sv + vi locales added on dev

* fix: back-merge v3.1.4 hotfixes into dev (#1371)

Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)

Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.

* fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)

- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.

* feat(planner): add an "Open in OpenStreetMap" button to the place inspector

Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.

Strings across all 22 locales; a unit test for the URL builder.

* feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap"

* feat(planner): show a day's route distances inline on mobile

Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.

The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.

Discussion #1374

* feat(oidc): use the picture claim as avatar when none is uploaded

When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399

* feat(trips): trip invite links + optional trip binding on admin invites

Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.

Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.

Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.

Discussion #1143

* fix(join): extract JoinTripPage state into a useJoinTrip hook

The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.

* feat(costs): filter expenses by category and by a single day

Adds two filter dropdowns next to the Search Expenses field (height-matched
to it): one filters by expense category, the other narrows to a single day.
Selecting a day shows a prominent summary banner with that day's total, and
hides the now-redundant per-day header. Both filters work on the desktop and
mobile layouts and compose with the existing search + all/mine/owed filters.

New i18n keys (costs.filter.allCategories / allDays, costs.expensesCount)
across every locale.

* fix(admin): use TREK's CustomSelect for the invite trip picker

The "add to trip" dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(searchable once there are many trips).

* feat(planner): public transit routing via Transitous (#1065)

Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.

Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.

The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.

Discussion #1065

* fix(build): declare tz-lookup as a client dependency

It was present in the lockfile but undeclared, so the local install had it
while the Docker client build (npm ci --workspace=client) did not.

* test(maps): add buildUserAgent to the mapsService mock

transitService imports it at module load, and the full-app integration boot
now pulls the transit module in — the factory mock lacked the export.

* feat(planner): make transit journeys first-class entries (#1065)

A saved transit route is now its own reservation type instead of piggybacking
on train/bus: it gets a tram icon and its own color everywhere, and the day
timeline renders the itinerary inline — line badges in their official colors
with walk segments, plus the transfer count and walking time — instead of a
generic transport row.

Clicking the row opens the itinerary view (journey summary, stop-by-stop legs
with times, platforms, headsigns and operators) rather than the edit form;
editing stays reachable from an Edit action inside that view. The transit
type is registered across the timeline merge, transport modal, reservations
panel, file manager, map overlays and detail panels, with a translated type
label in every locale.

* feat(planner): integrate transit into the transport system as Automated mode

The add-transport dialog gains a Manual/Automated switch: Automated embeds the
public-transit search (day picker + from/to + modes + preferences + results)
right in the dialog, and the day header's tram button opens it directly in
that mode. The standalone search modal is gone.

Saved journeys get their own roomy journey view — the stop-by-stop itinerary
(times, platforms, lines, headsigns, operators) together with the editable
booking fields, delete, and a "Change route" action that re-runs the search
pre-seeded with the journey's origin/destination and replaces the itinerary on
save. The generic transport form no longer opens for transit entries, from the
timeline or from the Transports tab, where journeys now sit in their own
"Automated public transit" section with their line badges on the card.

New i18n keys in every locale; wiki updated.

* feat(planner): polish the transit journey UI and fix its tab placement

Transit entries were classified as bookings by the planner's transport-type
list and landed in the Bookings tab — they now sit in the Transports tab's
own section, rendered as proper journey cards (tram icon, arrow title, leg
chips, journey stats) instead of the generic booking card.

The journey modal got a redesign: the title renames inline in the header
with an icon arrow, the stats become three full-width tiles (duration /
transfers / walking, each with an icon), status and booking-code fields are
gone, and notes take the full width with a markdown write/preview toggle.
The Automated search mode gains a proper header (icon, hint, day picker)
and the day-plan row now shows walks with their minutes inside the chip
sequence (🚶 3 › U2 › 🚶 3) instead of a detached direct/walk summary.
"A → B" titles render with an arrow icon everywhere. The Transports tab's
add button is simply "Transport" now that the dialog covers both modes.

* test(nav): the bottom-nav add button is labelled Transport now

* feat(planner): markdown toolbar for journey notes + calmer transit search form

The journey notes gain a proper markdown toolbar (bold, italic, strike,
heading, list, checklist, link, code) that wraps the selection or prefixes
the current lines. The transit search options settle into one calm card:
depart/arrive + time + date and the ranking preference share the top row,
the mode filters and the search button share the bottom row, with the mode
chips restyled from heavy filled pills to quiet toggles. The day-plan row
drops the transfer count — the leg chips already tell the story.

* feat(planner): badge meta rows + inline itinerary expansion for transit

Dot-joined meta text becomes quiet badge chips everywhere transit facts are
listed: the journey modal's per-leg line (time, duration, stops, headsign
with an arrow icon, operator de-emphasised), the search results' leg details,
and the Transports-tab journey card (day, date, time span, duration — the
transfer count is gone from the card).

The day-plan transit row swaps the map-connections toggle for an expander:
the chevron folds the stop-by-stop itinerary out right inside the timeline —
times, line badges, stations with platform and stop counts — sized for the
sidebar.

* feat(planner): walk legs as centred dividers + journey-card note line

Walk segments in the journey modal and the day-plan inline itinerary
collapse from two lines into a single centred divider — dashed rules left
and right, the walk in the middle (foot icon, destination, minutes). Leg
meta badges sit tighter under their titles. The Transports-tab journey card
shows a dimmed first-line note preview, and the journey modal now reads the
reservation from the live store, so an update is visible the moment the
entry reopens.

* feat(map): draw transit journeys along their real rail and bus alignments

MOTIS leg geometry (encoded polylines) now travels through the proxy and is
stored per leg, so both map renderers draw the journey along the actual
tracks instead of a straight line: colored cores in each line's GTFS color
over a white casing, walks as dotted grey connectors. Transit journeys are
always visible on the map — they are part of the plan itself, not an opt-in
overlay — and the day route already anchors to their stations, so the
journey slots into the route computation end to end. Entries saved before
this keep the straight-line fallback.

Also: stronger dashes on the walk dividers, notes open rendered (preview
tab) when present, and MOTIS's START/END placeholders are replaced with the
places the user actually picked.

* fix(planner): elegant walk-divider hairlines + proven notes preview

The walk dividers switch from dashed borders to 1px hairlines that fade
towards the outer edges — strongest next to the walk text. A regression
test pins the journey modal opening existing notes on the rendered
markdown preview rather than the raw text.

* fix(map): transit polish — earlier label collapse, route-toggle coupling, md note preview

Station badges on transit journeys collapse to icon dots much earlier when
zooming out (label threshold 900px instead of 400). The drawn transit paths
now ride the day-route toggle: turning the route off hides them too, since
they are part of the computed route. The journey card's note preview renders
its first line as inline markdown instead of raw asterisks.

* fix(settings): booking route labels default to off

The map endpoint labels only render when the user explicitly enables them;
an unset preference now means hidden, matching the calmer default the
transit paths brought to the map.

* style(settings): TREK-styled text-size sliders

The appearance tab's native range inputs become proper TREK sliders: a thin
pill track filled up to the current value in the accent color, with a soft
round thumb that scales slightly on hover/drag.

* fix(planner): mobile layouts for the transit popups

The journey modal and the transit search now lay out properly on phones:
from/to stack vertically with the swap rotated between them, the ranking
segment and search button go full width, the day picker in the automated
header spans the row, the three stat tiles compress to centred mini tiles,
the itinerary tightens its gutters, and the footer wraps with an icon-only
delete. Desktop is unchanged.

* fix(planner): tighter mobile transit search + vertical journey itinerary

* fix(planner): wrap-safe mobile itinerary text — platform below the stop, minutes-first walks

* feat(plugins): plugin system scaffold — registry tables + admin panel

First slice of the plugin system. Lays down the data model and a read-only
admin surface; nothing executes yet.

- Migration 155: plugins, plugin_meta_migrations, plugin_error_log and
  plugin_settings_fields tables. Plugin data will live in a per-plugin sqlite
  file under /plugins-data, never in these tables.
- New Nest module server/src/nest/plugins with GET /api/admin/plugins
  (admin-gated, returns the installed list + the runtime-enabled flag).
- TREK_PLUGINS_ENABLED kill switch (config.pluginsEnabled), off by default.
- Admin → Plugins tab with a read-only panel: installed list, status badges,
  and a clear banner when the runtime is disabled by server config.
- i18n keys for the tab and panel across all locales.

Install, activation, the isolated runtime and the registry browser follow in
later slices.

* feat(plugins): isolated per-plugin runtime + capability RPC (M1)

Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.

- protocol/envelope: the wire types + method→permission map (pure, shared by
  host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
  BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
  ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
  ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
  graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution

Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.

* feat(plugins): activation, HTTP route proxy + instance settings (M2)

Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.

- PluginRuntimeService owns the supervisor: activate spawns the child with its
  granted permissions + decrypted instance config, deactivate kills it, status
  and errors are persisted to the plugins / plugin_error_log tables, and active
  plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
  reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
  plugin's declared routes, enforces per-route auth (auth:false routes are public
  for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
  the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
  instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.

Photo/calendar hook consumers are deferred to a later slice.

* feat(plugins): sandboxed page/widget frames + trekBridge (M3)

Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.

- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
  guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
  to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
  so the frame can't read the session cookie or the parent DOM). Global CSP
  frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
  inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
  or origin; pushes context (theme/locale/tripId/userId), validates navigation,
  renders notifications as text, resizes widgets, and proxies trek:invoke to the
  plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.

Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.

* feat(plugins): secure installer — manifest, discovery, safe extract/fetch/scan (M4)

Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.

- manifest.ts: strict trek-plugin.json validation (id/version/type, known
  permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
  rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
  downgrades or wipes an already-installed plugin's status / grants / config.
  Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
  them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
  - safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
    redirect following + size cap + sha256 (constant-time compare).
  - safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
    readers; rejects traversal, absolute paths, symlinks, oversized/too-many
    entries, and unsupported formats.
  - native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.

* feat(plugins): TREK-side registry — browse + one-click install (M5)

Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.

- registry.service: fetches the single aggregated dist/index.json (never
  per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
  soft-fails to a stale/empty registry, and installs a pinned version through
  the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
  manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
  recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
  wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
  POST /api/admin/plugins/install { id, version }. Install never executes code;
  activation stays a separate, deliberate step.

* feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)

The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).

- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
  PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
  injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
  SAME permission model + membership checks, so authors can unit-test that their
  plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
  predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
  and trek-plugin validate (manifest + README sanity).

Consolidating the server loader to import this shared validator is a follow-up.

* docs(plugins): plugin wiki + reference plugin (M7)

- Wiki pages (sync to the GitHub wiki on push to main): Plugins overview + trust
  model, Plugin Development (SDK, definePlugin, ctx, routes/jobs, the client
  bridge, testing with the mock host), Plugin Permissions reference, and
  Publishing (registry PR + CI gates + provenance). Linked from the sidebar.
- Reference plugin plugin-sdk/examples/trip-countdown: a complete, minimal-
  permission widget (reads trip data through ctx, renders in the sandboxed
  iframe via the bridge, filled-in README). Validated in the SDK test suite so it
  passes the exact gate authors face.

* feat(plugins): lifecycle polish — uninstall, error log, egress guard, widget slot (M8)

- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
  plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
  error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
  request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
  host not in the plugin's declared egress[]; with none declared, all outbound is
  blocked. Process-level defense in depth (the container runtime enforces it at
  the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
  registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.

The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.

* docs(plugins): clarify the fork-and-PR publishing flow

* fix(plugins): allow GitHub's rotating release-asset host in the installer

GitHub 302-redirects release-asset downloads to a rotating *.githubusercontent.com
host (objects / github-releases / release-assets). The SSRF allowlist only had
objects.githubusercontent.com, so installs failed with 'host not allowlisted'.
Allow the whole *.githubusercontent.com suffix (plus github.com/codeload); the
private-IP check remains the SSRF backstop.

* fix(plugins): allow inline scripts in the sandboxed frame + fix server lint error

- Plugin frame CSP: the frame runs at an opaque origin (sandbox without
  allow-same-origin), so script-src 'self' matches nothing and the widget's own
  script never runs (stuck on 'Loading…'). Allow 'unsafe-inline' — the sandbox,
  not this directive, is the isolation boundary, and the plugin author controls
  the frame code either way.
- Fix a no-constant-binary-expression eslint error in registry.test.ts that was
  failing the server lint:check (eslint .) in CI.

* fix(plugins): exclude /plugin-frame/ from the service-worker navigate fallback

The PWA service worker's navigateFallback served the SPA shell for any
navigation not on its denylist. /plugin-frame/ wasn't listed, so the SW
intercepted the sandboxed opaque-origin plugin iframe navigation, which Chrome
reports as 'Unsafe attempt to load URL … from frame with URL …'. Denylist it so
plugin frames are served straight from the network.

* feat(plugins): pass the dashboard's spotlight trip id to widget plugins

Widget plugins now receive the current (spotlight) trip id in their bridge
context, so a widget like Trip Countdown can show a real countdown instead of
the empty state.

* fix(plugins): trips.getById returns the actual trip row, not the access check

canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').

* feat(plugins): persist enable-intent across restarts + redesign admin page

The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.

Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
  a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
  and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
  browser marks already-installed plugins. i18n across all locales.

* fix(plugins): backfill enabled for any plugin not explicitly deactivated

status at migration time can be error/stopped/starting after a crash or shutdown,
not just 'active' — so backfill enabled=1 for everything except 'inactive' (the
only status deactivate() sets).

* feat(plugins): hero widget slot + Koffi reference plugin

Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.

Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.

Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).

* feat(plugins): richer admin panel + registry detail view

Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.

Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.

Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.

* i18n: localize the plugins admin section across all locales

The admin.plugins block was still English filler in most locales; translate
it everywhere and add the new detail-view keys in all 22 languages.

* feat(plugins): denser browse grid + prominent install-risk disclaimer

Four cards per row on desktop with tighter card padding, and a full-width
warning banner above the browse grid: installs are at the admin's own risk,
a prior quick review does not rule out harmful content, inspect a plugin
yourself when in doubt — TREK accepts no responsibility. All 22 locales.

* feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures

Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).

* fix(plugins): make the permission-model child load from the real plugin path

The prod data dir is a symlink (server/data -> volume), so resolving the plugin
under it tripped the permission model, and Node's module-type lookup walked up
into the (denied) data dir. Fork the child from the plugin's realpath and drop a
{"type":"commonjs"} package.json at its root so resolution stops there — trek.db
and the secret files stay unreadable, verified against Koffi.

* test(plugins): cover pluginRealCodeDir fallback + ensurePluginModuleType

* feat(plugins): zero-config L1 hardening — SSRF egress, RSS reaper, capability audit, no popups

Security that ships from the install itself, no self-hoster setup:

- Egress SSRF/rebinding backstop: the net.Socket connect guard now RESOLVES the
  destination and refuses private/loopback/link-local/metadata/CGNAT/ULA
  addresses, pinning the resolved IP (a declared host that re-resolves to an
  internal address is blocked). Pure policy in egress-policy.ts + tests.
  TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on opts back into internal targets.
- RSS memory reaper: the supervisor now kills a child that blows a real RSS
  ceiling (TREK_PLUGIN_MAX_RSS_MB, default 300) — --max-old-space-size only
  bounds the V8 heap, so Buffers could OOM the box under it.
- Hash-chained capability audit log (migration 159): every core-data / broadcast
  call is recorded at the RPC boundary with the host-bound acting user and a
  per-plugin hash chain, so wide grants stay attributable + tamper-evident.
  Admin endpoint GET /api/admin/plugins/:id/audit.
- Drop allow-popups from the plugin frame (sandbox + CSP): window.open ignores
  connect-src, so it was an egress/phishing bypass.

Server suite 207 green, client + migration reconciliation green.

* fix(plugins): close the UDP + DNS egress hole in the network guard

The egress guard only wrapped fetch and net.Socket.connect, so TCP and
HTTP were contained but two channels stayed wide open: a plugin could
send data out over UDP (node:dgram) or tunnel it inside DNS queries
(dns.resolveTxt & friends) to any host it never declared. Neither goes
through net.Socket.connect, so the allowlist never saw them.

Wrap both now against the same declared-host allowlist:
- dgram send/connect: the explicit destination is allowlisted and
  private-IP-checked like a TCP connect (a null address keeps the
  connected/localhost default, which the connect wrapper already vetted).
- the dns resolver family (module fns, dns.promises, Resolver.prototype):
  a forward lookup for an undeclared name is refused, which kills DNS
  tunnelling even when no socket is ever opened.

A plugin with no declared egress now really has no way out.

* feat(plugins): re-consent gate when an update wants new permissions

Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.

Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
  but the plugin is left OFF, and the delta is handed back so the admin
  has to approve it before it turns on.

Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.

* feat(plugins): honest security info in the admin panel + update consent UI

Reworks how the plugins panel talks about safety, since the old copy
oversold it. Drops the "install at your own risk" banner and the generic
trust note, and replaces them with:

- a collapsible security section that lays out plainly how a plugin is
  contained, what the permissions actually mean (a hard limit on what a
  plugin CAN do, not a promise of what it does), where the limits are,
  and what a hostile plugin could do at worst.
- a short note on what "Reviewed" means: a maintainer scanned it for
  malware each version, not for quality — not a guarantee it's harmless.
- the consent dialog for the update flow: when an update asks for rights
  you never granted, it lists the new permissions and outbound hosts and
  makes you approve before the plugin turns back on.

Full copy in all 22 locales.

* feat(plugins): redesign the admin plugins page — search, filters, cleaner cards

The panel was cramped and hard to scan. Rebuilt it as a proper management
surface:

- A segmented Installed/Discover switch with counts, and a real toolbar:
  search, filter by type, filter by status (active/off/update/error), and
  sort (name/recent/updates first).
- An "N updates available · Update all" bar.
- Installed rows are tidied up: a single health dot on the icon tile
  instead of a wall of badges, and capability chips underneath that show
  what each plugin can actually reach at a glance (reads your trips,
  dashboard widget, the hosts it talks to) — the reach is now visible
  without opening anything. Update, toggle and a ⋯ menu (restart, errors,
  source, uninstall) sit on the right.
- The registry browser is now an App-Store-style card grid: screenshot
  with the plugin's icon chip, a reviewed badge, consistent heights.
- The detail dialog gained "What it can access", "Connects to" and a
  details grid (version, size, requires, reviewed).

To feed the capability chips, the installed list now returns each plugin's
declared permissions and capabilities. New copy is in all 22 locales.

* feat(plugins): make the plugins admin page work on small screens

The redesign was built desktop-first. On a phone the toolbar wrapped into
a mess and the rows were too cramped. Reworked the responsive behaviour:

- The toolbar stacks on mobile — tabs + rescan on top, full-width search,
  then a right-aligned filter row — and collapses back into one row on
  sm+ (via display:contents), so the desktop layout is unchanged. Filter
  buttons drop their label on mobile and lead with an icon; their menus
  are capped to the viewport width so they never push the page sideways.
- Installed rows use tighter spacing on mobile and the update button
  shrinks to just its icon (full label from sm up).
- Horizontal padding, the discover grid and the detail dialog all get
  mobile-friendly spacing.

* fix(plugins): make the detail dialog screenshot fill the full width

aspect-[16/9] together with max-h-64 made the browser shrink the image
width to keep the ratio once the height was capped, leaving a grey strip
on the right. Drop the max-height so the header image spans the dialog.

* feat(plugin-sdk): one-command publishing — pack, entry, release

Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:

- `trek-plugin pack` builds plugin.zip in the exact layout the installer
  reads (own tiny zip writer, so the SDK stays dependency-free and the
  format can't drift from the reader), enforces the same native-binary and
  size rules, and prints the sha256 + size. docs/ is left out — the store
  fetches the screenshot from the repo, so it doesn't belong in the install
  artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
  + the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
  size, and minTrekVersion derived from the manifest's trek range. `--merge`
  prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.

Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.

* docs(plugins): rewrite the plugin wiki against the current code + tooling

The plugin wiki had drifted from the app and the SDK. Rewrote all four
pages, verifying every command, permission, field, path and UI behaviour
against source:

- Plugins: activation is a toggle (no separate consent screen); install is
  the Discover tab (no "Browse plugins" button); you review permissions in
  the detail modal before installing; documents update + re-consent, the ⋯
  menu, toolbar filters, capability chips and the health dot.
- Plugin-Development: full manifest reference; ws:broadcast:trip/:user (there
  is no ws:broadcast:*); onLoad + onUnload; the trek:error bridge message and
  full context payload; trips.* only work in a route handler; asUserId is
  accepted-but-ignored; integration hooks are declared but not yet wired.
- Plugin-Permissions: db:own also covers db.migrate; a host must appear as
  both an http:outbound:<host> permission and an egress[] entry or it's
  silently blocked; bare vs per-host outbound.
- Plugin-Publishing: the new one-command flow (validate → pack → release →
  entry), size is a required entry field, signing reconciled with the schema,
  no reserved namespaces, and the --merge update path.

* chore(plugin-sdk): make it npm-publishable so `npx` resolves for authors

The docs told authors to run `npx create-trek-plugin` / `npx trek-plugin`,
but nothing published under those names, so npx couldn't resolve them.

- Ship one package, `trek-plugin-sdk`, with a bin that matches the package
  name (`trek-plugin-sdk`) so `npx trek-plugin-sdk <command>` resolves with
  zero install. The dispatcher gained a `create` subcommand, so every step
  (create/validate/pack/entry/release) runs through that one entry point.
  The short `trek-plugin` / `create-trek-plugin` bins still work once
  installed.
- Package hardening for publish: repository+directory (monorepo subdir),
  homepage/bugs/author/engines, publishConfig public, a prepublishOnly that
  builds + tests, and a LICENSE file.
- A publish workflow: pushing a `plugin-sdk-v*` tag builds and publishes with
  the NPM_TOKEN repo secret.
- Docs (SDK README + the four wiki pages) now use `npx trek-plugin-sdk <cmd>`,
  the invocation that actually resolves.

* feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard

Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.

- `dev`: run a plugin locally with a real request loop and hot reload — no full
  TREK. Injects a ctx that enforces the manifest's granted permissions (an
  ungranted call throws, so you catch a missing grant), backs db:own with a real
  SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
  and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
  manifest parity, artifact sha256/size, native scan, README quality gate) so a
  green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
  push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
  author signatures over the artifact bytes, verified 1:1 against the server's
  TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.

24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).

* feat(plugin-sdk): one-command `publish` (pack → release → preflight → PR)

Collapses the release into a single command: pack the artifact, tag + create the
GitHub release, run the registry CI checks locally (preflight), and open the
registry PR — stopping before it submits if preflight would fail, so a broken
entry never becomes a doomed PR. `--sign` signs it; `--no-preflight` skips the
gate. The individual pack/release/preflight/submit commands still exist.

README + the Development/Publishing/Permissions wikis lead with `publish` now.

* fix(plugins): security hardening from the PR #1415 audit

Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:

Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
  and drop location/content-disposition + non-2xx from the passthrough, so a
  plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
  takeover) or an open redirect.

High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
  row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
  event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
  the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
  activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
  before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
  consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
  consent dialog, which now queues instead of overwriting.

Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
  low-level socket escape (process.binding) + lock the wrapped prototypes;
  canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
  degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
  users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
  reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
  validator gains the reserved-id + outbound-host checks; wikis corrected.

Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.

* fix(plugins): close the 4 PARTIAL findings + regressions from the fix-verify pass

A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:

Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
  ZERO permissions (granted '[]') was treated as never-consented and a later
  widening was granted silently. Now discovery marks a never-consented plugin with
  granted_permissions '' and activate() gates on "ever consented" (any non-empty
  string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
  synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
  IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
  to sandboxed iframes; exposure is the plugin's own routes + already-held context,
  never the httpOnly cookie).

Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
  Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
  through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
  activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
  keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.

Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.

* docs: refresh README screenshots (8) + swap the second trip shot for Collections

Replaces all eight README gallery screenshots with current-UI captures and swaps
docs/screenshots/trip-iceland.png for collections.png (saved place lists).

* fix(plugin-sdk): make require('trek-plugin-sdk') actually resolve everywhere

A freshly scaffolded plugin could not load anywhere: the npm package is
ESM-only (no require condition in its exports map), so the scaffold's
require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under
`trek-plugin dev` - and the runtime injection the wiki promised for the
plugin child never existed, so a packed plugin (node_modules stripped)
crashed with MODULE_NOT_FOUND after a real install.

- plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim
  for require('trek-plugin-sdk'); subpaths fail with a pointed error
- trek-plugin dev: inject the exact same shim, so a fresh scaffold runs
  with zero npm install and dev parity with production holds
- npm package: ship a real CommonJS build (dist/cjs + require export
  conditions) so the installed package also requires cleanly on Node 18+
- create: scaffold a package.json (type commonjs, SDK as devDependency,
  npx scripts); print resolvable `npx trek-plugin-sdk ...` hints
- wiki: package.json in the scaffold tree + publishing checklist, and
  document the zero-install dev flow

* fix(plugins): tolerate a UTF-8 BOM in trek-plugin.json

Windows editors love to prepend a BOM, and a bare JSON.parse then dies
with an "Unexpected token" pointing at an invisible character - in the
SDK CLIs (dev/validate/entry/submit) and, worse, server-side: a BOM in
an author repo travels through pack into the artifact and fails
discovery and registry install. Strip it at every manifest/JSON read
(readJsonFile in the SDK, parseJsonText in the installer).

* fix(plugin-sdk): dev db binds an args array like the real host, and a failed onLoad stops the routes

* ci(plugin-sdk): publish on Node 22; skip the dev-db bind test without node:sqlite

* docs(wiki): document AI booking import, guest members and packing sharing

Fill the gaps left after the 3.2.0 feature work:
- add an AI Booking Import page for the AI Parsing addon (providers,
  admin/per-user config, model pull, the review-before-save flow) and link
  it from Reservations & Bookings and the sidebar
- document guest members on Trip Members and Sharing (owner-only, what they
  can be assigned to, and the sign-in/notification/visibility limits)
- document the three packing sharing tiers and co-bringing on Packing Lists
- add TRANSIT_API_URL and the plugin variables to Environment Variables,
  and correct the language list to 22 (add Swedish and Vietnamese)
- list the airtrail and llm_parsing addons in the Addons overview

* feat(plugins): enable the plugin system by default

The runtime and the Admin -> Plugins panel are now available out of the box;
TREK_PLUGINS_ENABLED becomes an opt-out (set it to false to switch the whole
system off). Installed plugins are still registered inactive and have to be
activated one by one, so no third-party code runs until an admin turns a
specific plugin on.

Update the kill-switch default test and the plugin/env-var wiki pages to match.

* fix(costs): KGS is selectable as default/expense currency (#1400)

* fix(map): render date-line-crossing routes as one continuous arc (#1411)

The great-circle sampler normalizes longitudes to [-180,180], so a
transpacific leg jumped +-360 between neighbours and got split into two
polylines pinned to opposite map edges. Unwrap the longitudes instead
(shared flightGeodesy module for both renderers): Leaflet additionally
draws a +-360-shifted copy so both halves show in the standard view, GL
maps repeat world copies themselves.

* fix(map): clear the hover card on marker click and camera moves (#1404)

Clicking an off-center place recenters the map under a stationary
cursor, so mouseout/mouseleave never fires and the hover card sticks.
Clear it on marker click and on movestart, and suppress re-shows while
the camera is animating (marker rebuilds re-fire mouseenter mid-pan).

feat(map): long-press + plain right-click add-place on GL maps (#1398)

The GL providers only bound middle-click, so mobile had no way to add a
place at a position (and Macs have no middle button). Add a 600ms touch
long-press with move tolerance and the map contextmenu event - both GL
libs suppress it while the right-button rotate/pitch drag is active, so
the gesture keeps winning.

* fix(mcp): keep SSE streams alive and stop invalidating sessions on unrelated saves (#1414)

Three separate causes for the reconnect-per-tool-call pain:
- no keep-alive on the standalone GET stream, so reverse proxies with
  idle timeouts (nginx default 60s) killed it between calls - send an
  SSE comment ping every 25s (MCP_SSE_KEEPALIVE, 0 = off) and count an
  open stream as session activity
- the session TTL was hard-coded - MCP_SESSION_TTL (seconds, clamped to
  24h) now works as the issue expected
- every addon save invalidated ALL sessions: config-only saves, photo
  provider toggles and addons with no MCP surface included. Only a real
  enabled-flip of an MCP-relevant addon (or an actual collab-feature
  change) tears sessions down now.

* feat(api): OpenAPI/Swagger docs at /api/docs behind TREK_API_DOCS_ENABLED (#1412)

Swagger UI + raw spec (/api/docs-json, -yaml) over all controllers, with
a bearer button that works with a plain session JWT. Off by default -
the spec enumerates the whole surface incl. admin routes, so exposing
it is an explicit self-hoster decision (same kill-switch pattern as
TREK_PLUGINS_ENABLED).

Request bodies come from the Zod schemas the routes already validate
with: an enricher walks every controller, finds whole-body
ZodValidationPipe params and lifts their schema into the document via
zod v4's native z.toJSONSchema - nothing is annotated twice, and any
route that gains a Zod pipe is documented automatically.

* fix(map,mcp): review follow-ups for the issue-fix batch

- mapbox-gl (unlike maplibre) still emits the map contextmenu after a
  right-button rotate/pitch drag on Windows - guard it with the pressed
  position so ending a rotate can't open the Add-Place form (#1398)
- a long-press whose fire was deduped (or that never yields a click) no
  longer leaves suppressNextClick armed to swallow a later real tap
- MCP_SSE_KEEPALIVE=0 keeps the open-stream-counts-as-activity
  guarantee: the touch interval survives, only the pings stop (#1414)
- swagger-ui-dist ships @scarf/scarf install-time analytics - disabled
  via scarfSettings in the root package.json, TREK sends no telemetry
- Budget wiki currency list: 47 incl. KGS (#1400)

* feat(map): real road routes for car/bus/taxi/bicycle bookings instead of straight lines

Road-based transport bookings drew an as-the-crow-flies line; only
transit journeys (Transitous) showed the real path. A shared
useTransportRoutes hook now fetches the OSRM road geometry (driving for
car/bus/taxi, cycling for bicycle) — reusing the day-route router and
its cache — and both renderers draw it in place of the straight arc,
falling back to the straight line until it loads or if routing fails.
Trains/other keep their straight line (not road-routable); a 2000 km
sanity cap avoids hammering the public router on cross-continent quirks.

* feat(transport): multi-leg train bookings (#1150)

Long train trips are usually several trains under one booking. Trains
now get the same multi-leg editor flights have: an ordered chain of
stations (station search instead of the airport picker) with a per-leg
train number + platform, saved as from/stop/to endpoints + metadata.legs
— mirroring the flight leg contract, so the map draws the whole chain
and the day plan splits it into one row per leg (drag/reorder/position
persistence come for free from the shared __leg machinery). A single-leg
train saves exactly as before (flat metadata, no legs), and the flat
train-fields block is gone in favour of the per-leg inputs. Day sidebar,
shared trip view and the PDF render each train leg like a flight leg.

* feat(collections): per-collection custom labels

Each list can now define its own labels (e.g. Berlin, Hamburg, Ostsee in a
"Germany 2026" list) and organise its places by them:

- manage labels (create / rename / recolor / delete) from a label manager
- assign labels to a place from its detail sheet, or to many places at once
  from the selection toolbar
- filter the place list AND the map by label (multi-select, any-match)

Labels are scoped to a collection and shared by all its members. Managing and
assigning labels needs edit rights; filtering is available to everyone. Moving
a place to another list drops its labels, since they belong to the source list.

* test(collections): pass the required labels prop in CollectionPlaceDetail test

The per-collection labels feature (a5522e99) made `labels` a required prop
and renders `labels.filter(...)`, but the test's props cast to
Omit<DetailProps,'t'> hid the missing prop, so `labels` was undefined at
runtime and crashed the whole suite (Cannot read properties of undefined
reading 'filter'). Pass labels: [] like categories.

* docs(wiki): document collection labels, multi-leg trains and road-route overlays

- Collections: add a Custom labels section (manage / assign / filter), note the
  label filter + bulk assign, and the view-vs-edit permission split
- Transport: rewrite the train fields as the multi-leg route editor, correct the
  transport type list (nine types) and the map/day-plan behaviour
- Map Features: car/bus/taxi/bicycle overlays follow real roads; multi-leg trains
  draw their full station chain; date-line routes render as one continuous arc

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <jeffturner93@gmail.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Zorth Thorch <jasper_goens@hotmail.com>
Co-authored-by: leeduc <lee.duc55@gmail.com>
Co-authored-by: yael-tramier <tramier.yael@gmail.com>
Co-authored-by: michael-bohr <mjbohr@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gio Cettuzzi <gio.cettuzzi@gmail.com>
Co-authored-by: mauriceboe <mauriceboe@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-05 01:13:04 +02:00
github-actions[bot] 75e3bb3985 chore: bump version to 3.1.4 [skip ci] 2026-07-01 16:57:52 +00:00
jubnl 819aa793ae v3.1.4 (#1371)
* fix(backups): prevent recursion in path that is backed up

* fix(share): show user currency instead of the default euro in the share page

* fix(files): show descriptive error for unsupported upload type

Unsupported file uploads showed a generic 'Upload failed' toast even
though the server already returns a descriptive 400. The client catch
blocks discarded the error and always showed t('files.uploadError').

The server now emits the i18n key 'files.uploadErrorType' as its error
message; a new translateApiError() helper resolves a server message that
is a known translation key via t() and falls back to the generic key
otherwise. Wired into the three trip-file upload catch sites.

Closes #1363
2026-07-01 18:57:16 +02:00
8302 changed files with 1301534 additions and 115337 deletions
+2 -2
View File
@@ -15,6 +15,8 @@ uploads
chart/
docs/
docker-compose.yml
docker-compose.minio-test.yml
minio-licence.key
unraid-template.xml
*.sqlite
*.sqlite-shm
@@ -30,9 +32,7 @@ Thumbs.db
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+6
View File
@@ -0,0 +1,6 @@
# Commits that only reformat code. git blame skips them with
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub reads this file on its own).
# style: format server and shared with prettier
724c88c201b2c16d31317eaea5f59f43d314b833
+3
View File
@@ -15,6 +15,7 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
@@ -25,3 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+2 -2
View File
@@ -8,11 +8,11 @@ body:
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
url: https://github.com/liketrek/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
url: https://github.com/liketrek/TREK/discussions
about: For questions and general help, use Discussions instead
+2 -2
View File
@@ -13,8 +13,8 @@
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
+71
View File
@@ -0,0 +1,71 @@
# Version updates, weekly, into dev. Minor and patch bumps of one ecosystem
# arrive as a single grouped PR; a major bump gets a PR of its own so its
# breaking notes are read on their own.
#
# Security updates are a repository setting, not part of this file, and
# GitHub opens them against the default branch whatever target-branch says.
# enforce-target-branch.yml moves those onto dev.
#
# Every PR runs test.yml like any other, so ci-ok decides whether it can
# merge. Dependabot runs get no Actions secrets, so s3-contract and the Sonar
# scan skip themselves on these PRs and ci-ok counts the skip as passed.
version: 2
updates:
# The root lockfile covers the client, server and shared workspaces.
- package-ecosystem: npm
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
prefix-development: chore(deps-dev)
groups:
npm-minor-and-patch:
update-types: [minor, patch]
# plugin-sdk is standalone with a lockfile of its own and ships to npm.
- package-ecosystem: npm
directory: /plugin-sdk
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(plugin-sdk)
prefix-development: chore(plugin-sdk)
groups:
plugin-sdk-minor-and-patch:
update-types: [minor, patch]
# Keeps the SHA pins of third-party actions current, comment included.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: ci
groups:
actions-minor-and-patch:
update-types: [minor, patch]
# The Dockerfile's base images. The Node major follows .nvmrc and the
# engines ranges, so it moves by hand, together with them.
- package-ecosystem: docker
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(docker)
ignore:
- dependency-name: node
update-types: [version-update:semver-major]
@@ -9,10 +9,12 @@ permissions:
jobs:
close-stale:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const badTitles = [
@@ -10,10 +10,12 @@ permissions:
jobs:
close-stale:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
+5 -3
View File
@@ -9,10 +9,12 @@ permissions:
jobs:
check-title:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Flag or redirect issue
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const title = context.payload.issue.title.trim();
@@ -76,7 +78,7 @@ jobs:
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
+81 -23
View File
@@ -1,5 +1,11 @@
name: Build & Push Docker Image (Prerelease)
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
# :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.
on:
workflow_dispatch:
inputs:
@@ -11,6 +17,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: prerelease-build
@@ -18,23 +25,42 @@ concurrency:
jobs:
version-bump:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# 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
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being built
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
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 prerelease version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -42,14 +68,12 @@ jobs:
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
@@ -62,38 +86,47 @@ jobs:
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Image metadata
id: meta
run: |
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -101,6 +134,9 @@ jobs:
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
- name: Export digest
run: |
@@ -109,7 +145,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -117,25 +153,27 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -143,10 +181,30 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
# The labels live on each platform image; the index gets the ones a
# client reads before it picks a platform (#1498).
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
--annotation "index:org.opencontainers.image.version=$VERSION" \
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
@@ -169,7 +227,7 @@ jobs:
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
BASE_VERSION="${VERSION%%-pre.*}"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
+150 -63
View File
@@ -1,20 +1,24 @@
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
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. 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.
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
type: choice
options: [auto, patch, minor, major]
default: auto
@@ -25,6 +29,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: stable-build
@@ -32,33 +37,66 @@ concurrency:
jobs:
version-bump:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
- name: Release from main only
if: github.ref != 'refs/heads/main'
run: |
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: ${{ secrets.GITHUB_TOKEN }}
token: ${{ steps.app-token.outputs.token }}
# 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
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being released
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
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
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
# 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)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
@@ -71,79 +109,96 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
# auto finalizes the in-flight prerelease and nothing else. It used
# to fall back to a patch, which would have shipped any change,
# however big, as a patch nobody chose.
PRE_BASE=""
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
exit 1
fi
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump, it would bypass confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
fi
# Patch releases have carried migrations before, so this only warns:
# whoever pinned :MAJOR.MINOR gets this schema change too.
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
fi
echo "Bump type: $BUMP"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# 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
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
# Commit and tag. The push fast-forwards main from the commit ci-ok
# was checked on, so it fails rather than release past anything that
# 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 commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin HEAD:main --follow-tags
# build, merge and release-helm check out exactly this commit.
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Image metadata
id: meta
run: |
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -151,6 +206,9 @@ jobs:
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
- name: Export digest
run: |
@@ -159,7 +217,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -167,23 +225,25 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Download build digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -191,12 +251,37 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
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
# release that migrates.
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
# The labels live on each platform image; the index gets the ones a
# client reads before it picks a platform (#1498).
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
--annotation "index:org.opencontainers.image.version=$VERSION" \
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$MINOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
@@ -204,16 +289,18 @@ jobs:
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
+36 -2
View File
@@ -6,7 +6,9 @@ on:
jobs:
check-target:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
pull-requests: write
issues: write
@@ -14,7 +16,7 @@ jobs:
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const base = context.payload.pull_request.base.ref;
@@ -67,6 +69,38 @@ jobs:
return;
}
// Dependabot's security updates cannot be pointed at dev. `target-branch`
// in .github/dependabot.yml applies to version updates only; security
// updates always open against the default branch. Labelling them meant
// close-stale-wrong-branch shut them 24 hours later, which is how 13 of
// them died. Retarget instead: a bot cannot click "Edit" the way the
// comment below asks a human to.
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
base: 'dev',
});
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
console.log('Retargeted the Dependabot PR onto dev.');
return;
} catch (err) {
// Retargeting can fail when the branch was cut from main and dev has
// moved on. Fall through to the normal path so a human sees it rather
// than the check passing on a PR that is still aimed at main.
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
}
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
+19 -8
View File
@@ -2,31 +2,41 @@ name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
node-version-file: .nvmrc
- name: Install dependencies
run: npm install
- name: Run lint & format check
# Both are check-only: lint used to run eslint --fix, which rewrote the
# files before format:check looked at them, so neither could fail on
# formatting. lint:format holds every file outside
# scripts/format-baseline.json to Prettier; the list only shrinks.
id: checks
continue-on-error: true
run: |
cd shared
npm run lint
npm run format:check
npm run lint:format
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
@@ -40,8 +50,9 @@ jobs:
'',
'```bash',
'cd shared',
'npm run lint',
'npm run format',
'npm run lint:fix',
'npx prettier --write <file> # each file lint:format names',
'npm run lint:format -- --update # when a listed file is formatted now',
'```',
'',
'Then commit and push the changes.',
+34
View File
@@ -0,0 +1,34 @@
name: Publish plugin-sdk to npm
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
# The package's prepublishOnly hook builds + tests before publishing.
on:
push:
tags:
- 'plugin-sdk-v*'
permissions:
contents: read
jobs:
publish:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# The Node the TREK image runs the plugins on. It has the node:sqlite
# the dev-server tests exercise.
node-version-file: .nvmrc
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+59 -9
View File
@@ -1,35 +1,85 @@
name: Security Scan
# Neither job is a required check. What they report depends on the advisory
# databases as much as on the change: a CVE published overnight would turn
# every open PR red at once, including the ones that cannot fix it. The
# security floors that hold the fixes live in the root package.json's
# overrides, which a regenerated lockfile keeps.
#
# - npm-audit reads the lockfile only, so it runs in seconds on every PR
# and push into dev and main. It audits what the image installs (the
# server and shared workspaces without dev dependencies) and the
# standalone plugin-sdk.
# - scout scans the built image, base image packages included. It needs
# the Docker Hub login, so it runs on PRs and pushes into main only and
# skips fork PRs. Whether the image builds at all is test.yml's
# docker-image job, which a red scan cannot hide.
on:
pull_request:
branches: [main]
branches: [main, dev]
push:
branches: [main]
branches: [main, dev]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scout:
runs-on: ubuntu-latest
npm-audit:
name: npm audit
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- uses: docker/build-push-action@v5
# The client's dependencies end up in a browser bundle, not in the
# image, and are audited by hand until maplibre-gl's fix (a major) lands.
- name: Audit the image's runtime dependencies
run: npm audit --package-lock-only --omit=dev --audit-level=high --workspace=server --workspace=shared
- name: Audit trek-plugin-sdk
working-directory: plugin-sdk
run: npm audit --package-lock-only --omit=dev --audit-level=high
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
# Scout login can never succeed there.
if: >-
github.repository == 'liketrek/TREK'
&& github.event.pull_request.head.repo.fork != true
&& (github.base_ref == 'main' || github.ref == 'refs/heads/main')
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@v1
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
with:
command: cves
image: trek:scan
+1044 -65
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -17,10 +17,12 @@ concurrency:
jobs:
deploy:
runs-on: ubuntu-latest
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
with:
strategy: clone
+6 -1
View File
@@ -4,6 +4,7 @@ node_modules/
# Build output
client/dist/
server/dist/
server/VERSION
shared/dist/
server/public/*
!server/public/.gitkeep
@@ -51,6 +52,7 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
@@ -64,5 +66,8 @@ coverage
.scannerwork
test-data
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
minio-licence.key
.run
.full-review
.full-review
+1
View File
@@ -0,0 +1 @@
24
+163
View File
@@ -0,0 +1,163 @@
# CLAUDE.md
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
## Commands
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
npm run format # prettier --write all three
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
```
Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate)
cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate)
cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate)
cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml)
cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate)
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate)
cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate)
cd client && npm run lint:skips # no .only; skipped tests may only shrink (CI gate)
cd client && npm run lint:i18n-keys # every key the client names exists in en; unused en keys may only shrink (CI gate)
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate)
cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
Run a single test file or test:
```bash
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). `.nvmrc` pins the Node major the Docker image runs and every CI job reads; `engines` in each package.json states the range that installs (npm only warns outside it). `server/.env.example` is the env-var reference.
## Server invariants
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `server/src/db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
## Client invariants
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too.
- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
## Project direction
These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. The Kysely table types the repositories query through are generated the same way (`gen:db-types`/`check:db-types`, `server/scripts/generate-db-types.ts`).
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
- **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current.
- **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there.
- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:<major>-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:<major>`, `:<major>.<minor>` and `:<version>`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:<major>.<minor>` can wait it out.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
| Condition (new code) | Must be |
|---|---|
| Coverage | ≥ 80% |
| Duplicated lines | ≤ 3% |
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
## Reference docs
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
+136
View File
@@ -0,0 +1,136 @@
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
people.
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
report@liketrek.com**.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
resolution.
## Addressing and Repairing Harm
****
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
skipped.
1) Warning
1) Event: A violation involving a single incident or series of incidents.
2) Consequence: A private, written warning from the Community Moderators.
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
clarification on expectations.
2) Temporarily Limited Activities
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
more serious violation.
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
seriousness of the situation and give the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions with particular community members.
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
impact, and being thoughtful about re-entering community spaces after the period is over.
3) Temporary Suspension
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
single serious violation.
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
4) Permanent Ban
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
this person as a member.
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
through other remedies has failed to change the behavior.
3) Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
the community in public or other spaces. Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
of this license,
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
For answers to common questions about Contributor Covenant, see the FAQ
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
enforcement and community guideline resources can be found
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
+3 -3
View File
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
## Development Environment
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+88 -40
View File
@@ -2,8 +2,10 @@
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
FROM golang:1.26-alpine AS gosu-build
# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever
# was pushed last, and the tag itself is not a semver version go install accepts.
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
@@ -20,13 +22,18 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
# A prerelease build does not bump client/package.json, so the bundle takes its
# version from the same build argument the server's VERSION file comes from.
ARG APP_VERSION=dev
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
@@ -35,7 +42,9 @@ COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
RUN npm run build --workspace=server
# tsc needs more than the 2 GB heap Node picks on a host with about 12 GB of RAM;
# on a bigger build machine the default is larger, so only small hosts failed.
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-trixie-slim
@@ -46,28 +55,32 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
# The trailing chown runs in this layer on purpose: it covers the manifests and
# the freshly installed node_modules while they are already part of this layer's
# changeset, so it costs nothing. Everything copied after this point carries
# --chown=node:node for the same reason — a recursive chown in a later layer
# would copy up every inode it touches and duplicate the whole tree in the image.
#
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
# python3 + build-essential fallback compiled it against whatever Node headers
# the base image had that day, and 24.19+ headers give an addon that aborts with
# "Assertion failed: (env) != nullptr" the first time a statement is collected
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
{ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
# it to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
@@ -77,40 +90,75 @@ ENV QT_QPA_PLATFORM=offscreen
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
# The in-app help pages (/help) read this straight from disk at runtime, so the
# docs always match the version running. Without it, wikiService falls back to
# fetching the GitHub wiki, which tracks main and needs network access.
COPY --chown=node:node wiki ./wiki
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
COPY --chown=node:node server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
# a dir created lazily on first upload needs write permission on the uploads
# mount point itself, which fails with EACCES when the bind-mounted host dir
# isn't writable by node. Only paths this layer creates are chowned; anything
# already in the image arrived node-owned via --chown above.
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
chown -R node:node /app/data /app/uploads && \
chown -h node:node /app/server/uploads /app/server/data
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
# The version goes into a file, not into ENV. A container keeps its environment
# when it is recreated on a newer image (Portainer's recreate, a copied run
# config), so an APP_VERSION env var would keep announcing the old release and
# the clients would never pick up the new bundle. The server reads this file
# first (server/src/app-config/image-version.ts).
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s\n' "$APP_VERSION" > /app/server/VERSION; fi
# OCI metadata: Renovate, Watchtower and the registries read the source label to
# link an image update to its release notes (#1498). The release workflows add
# the commit and the build time; a local build carries everything else.
LABEL org.opencontainers.image.title="TREK" \
org.opencontainers.image.description="Self-hosted collaborative travel planner" \
org.opencontainers.image.url="https://github.com/liketrek/TREK" \
org.opencontainers.image.source="https://github.com/liketrek/TREK" \
org.opencontainers.image.documentation="https://github.com/liketrek/TREK/wiki" \
org.opencontainers.image.licenses="AGPL-3.0-only" \
org.opencontainers.image.version="${APP_VERSION}"
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1
# Start-up lives in a script, not an inline `sh -c` string: container management
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
# carrying quotes does not survive that round-trip (the container then dies with
# a shell syntax error before Node starts). A CMD of one bare token does.
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
# node. chmod is explicit because a checkout on Windows carries no exec bit.
RUN chmod 0755 /usr/local/bin/trek-entrypoint
ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
CMD ["/usr/local/bin/trek-entrypoint"]
+1 -575
View File
@@ -1,577 +1,3 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Connect your MCP client
#### Option A: OAuth 2.1 (recommended)
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
## Tools (read-write)
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
+24 -2
View File
@@ -21,12 +21,34 @@ property names TREK consumes. Country borders (`admin0`) derive from the geoBoun
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
## OpenStreetMap — geocoding, place details & descriptions
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
details and descriptions come from Nominatim and **Overpass**. Data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## Wikimedia Commons & Wikipedia — place pictures & descriptions
Pictures offered for a place come from **Wikimedia Commons** (geosearch by
coordinate) and are cached locally rather than hotlinked. Descriptions can come
from **Wikipedia**, resolved from the place's OpenStreetMap `wikipedia` tag.
Commons files carry their own licence, most often **CC BY** or **CC BY-SA**. TREK
shows the author, the licence and a link to the file description page next to
every picture while it is being chosen, and keeps the author and licence with the
cached file so the credit stays visible afterwards. Wikipedia article text is
**CC BY-SA 4.0**. https://commons.wikimedia.org/wiki/Commons:Licensing
## Google Places — optional place data
When an administrator configures a Google Maps API key, place search, details,
pictures and editorial summaries can also come from the **Google Places API**,
subject to Google's terms. Photos carry the author attribution Google supplies
and no reusable licence; TREK displays that attribution and claims nothing
further. This path is off unless a key is configured, and each part of it has its
own switch in the admin panel. https://cloud.google.com/maps-platform/terms
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
+151 -220
View File
@@ -1,17 +1,16 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
<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 trips. Your plan. Your server." height="28" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -22,80 +21,84 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
&nbsp;
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
&nbsp;
<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/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></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/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/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>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK2.webp" alt="TREK, a tour of the app" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
#### 🧭 Planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
#### 🧳 Bookings and money
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
@@ -104,64 +107,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 Journal, Atlas and Vacay
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
#### 🧩 Plugins
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
#### 📱 Mobile and offline
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
#### ⚙️ Admin, accounts and security
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
@@ -171,6 +181,26 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<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 />
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
maintainer has not read and understood: every change goes through a pull request, is
reviewed and tested, and has a human who can answer for it. "The AI wrote that" is not
an answer any of us would accept from ourselves.
See [**How we use AI in TREK**](https://github.com/liketrek/TREK/discussions/1851) for
the details.
<br />
## Get started in 30 seconds
```bash
@@ -183,7 +213,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>
</div>
@@ -193,7 +223,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js_24-339933?style=flat-square&logo=node.js&logoColor=white)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_19-61DAFB?style=flat-square&logo=react&logoColor=black)
@@ -211,76 +241,29 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
with secure defaults and every option documented inline. Download it, then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
for the full walkthrough.
<br />
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
<br />
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
@@ -296,41 +279,17 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating
**Docker Compose:**
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<br />
<h2 id="reverse-proxy">Reverse Proxy</h2>
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
<details>
<summary>Nginx</summary>
@@ -368,6 +327,19 @@ server {
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
# so buffering must be off or tool results arrive late.
location /mcp {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
```
@@ -390,60 +362,18 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
<details>
<summary><b>Full reference</b></summary>
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
<br />
## Star History
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
<br />
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
<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
@@ -452,7 +382,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/liketrek/TREK</WebPage>
<Forum>https://github.com/liketrek/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+9 -2
View File
@@ -8,18 +8,20 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
- Configurable health checks (liveness/readiness probes) on `/api/health`
## Helm Repository
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
## Usage
Or install directly from the local chart:
@@ -40,11 +42,16 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.3
version: 4.3.3
description: Minimal Helm chart for TREK app
appVersion: "3.1.3"
appVersion: "4.3.3"
+11 -2
View File
@@ -2,8 +2,11 @@
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
@@ -21,3 +24,9 @@
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
5. Persistence:
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
+45
View File
@@ -13,6 +13,15 @@ data:
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.TREK_WIKI_DIR }}
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
{{- end }}
{{- if .Values.env.DEFAULT_LANGUAGE }}
DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }}
{{- end }}
{{- if .Values.env.MCP_MAX_SESSION_PER_USER }}
MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
@@ -40,6 +49,9 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
@@ -58,9 +70,21 @@ data:
{{- if .Values.env.OIDC_ADMIN_VALUE }}
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
{{- end }}
{{- if .Values.env.OIDC_USERNAME_CLAIM }}
OIDC_USERNAME_CLAIM: {{ .Values.env.OIDC_USERNAME_CLAIM | quote }}
{{- end }}
{{- if .Values.env.OIDC_SCOPE }}
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
{{- end }}
{{- if .Values.env.HTTP_PROXY }}
HTTP_PROXY: {{ .Values.env.HTTP_PROXY | quote }}
{{- end }}
{{- if .Values.env.HTTPS_PROXY }}
HTTPS_PROXY: {{ .Values.env.HTTPS_PROXY | quote }}
{{- end }}
{{- if .Values.env.NO_PROXY }}
NO_PROXY: {{ .Values.env.NO_PROXY | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}
@@ -70,9 +94,30 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
{{- if .Values.env.NOMINATIM_URL }}
NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
otherwise drop the key and leave the index on. */}}
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
{{- if .Values.env.VAPID_PUBLIC_KEY }}
VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }}
{{- end }}
{{- if .Values.env.VAPID_SUBJECT }}
VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }}
{{- end }}
+88 -13
View File
@@ -6,6 +6,12 @@ metadata:
app: {{ include "trek.name" . }}
spec:
replicas: 1
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
# RollingUpdate would start a second pod holding the same volume before the old one
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
# the old pod down first. Keep it, see updateStrategy in values.yaml.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
matchLabels:
app: {{ include "trek.name" . }}
@@ -34,10 +40,14 @@ spec:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 3000
- name: http
containerPort: {{ .Values.env.PORT | default 3000 }}
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
{{- with .Values.extraEnvFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
env:
- name: ENCRYPTION_KEY
valueFrom:
@@ -63,27 +73,92 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
- name: UNSPLASH_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
- name: PLACES_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: PLACES_API_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_KEY
optional: true
- name: AMAP_API_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_SECRET
optional: true
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: VAPID_PRIVATE_KEY
optional: true
- name: SMTP_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: SMTP_PASS
optional: true
- name: MAPBOX_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: MAPBOX_ACCESS_TOKEN
optional: true
- name: CARTO_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: CARTO_API_KEY
optional: true
- name: DEMO_ADMIN_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: DEMO_ADMIN_PASS
optional: true
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).readiness }}
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
- name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
+3 -1
View File
@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,7 +18,9 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+59 -5
View File
@@ -17,6 +17,33 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -31,17 +58,44 @@ metadata:
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec | quote }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | quote }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | quote }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }}
{{- end }}
{{- end }}
+1 -1
View File
@@ -8,7 +8,7 @@ spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
targetPort: http
protocol: TCP
name: http
selector:
+134 -5
View File
@@ -4,6 +4,12 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and
# during a RollingUpdate the new pod migrates the database while the old one still
# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both
# processes on one host, which network filesystems do not provide.
updateStrategy: Recreate
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -19,10 +25,16 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# TREK_WIKI_DIR: "/app/wiki"
# Where the in-app Help pages (/help) read their content from. Leave unset: the
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
# the version you are running. Only set this to serve your own docs from a mounted
# volume. If the path does not exist, Help falls back to fetching the public GitHub
# wiki, which needs egress and tracks the latest release rather than your version.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
@@ -43,6 +55,9 @@ env:
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# ALLOW_LINK_LOCAL_IPS: ""
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
@@ -57,22 +72,58 @@ env:
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
# Value of the OIDC claim that grants admin role.
# OIDC_USERNAME_CLAIM: ""
# Claim a new account's username comes from, e.g. "preferred_username". Unset: name, then preferred_username.
# OIDC_SCOPE: "openid email profile groups"
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# HTTP_PROXY: ""
# HTTPS_PROXY: ""
# NO_PROXY: "localhost,127.0.0.1"
# Outbound proxy for TREK's requests to the internet; NO_PROXY lists hosts that bypass it.
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# NOMINATIM_URL: ""
# 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.
# 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
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# OVERPASS_TIMEOUT_MS: "25000"
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# 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.
# 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
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# VAPID_PUBLIC_KEY: ""
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
# means every device has to turn push on again.
# VAPID_SUBJECT: ""
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
# address is sent unless you set one here.
# Secret environment variables stored in a Kubernetes Secret.
@@ -92,26 +143,104 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# Optional Google Maps API key (Places API (New) enabled) for place search, photos,
# details and Google transit. Overrides the key set in Admin > Settings > API Keys,
# whose field then shows this variable read-only. Leave empty to use the key from the
# admin panel, or none.
PLACES_API_KEY: ""
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
# key from the admin panel, or none.
AMAP_API_KEY: ""
# The private secret (数字签名) of that key. Required exactly when the key was created
# 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 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.
# Leave both empty to let TREK keep a generated pair in its database.
VAPID_PRIVATE_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades.
# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux
# the chart cannot read the existing secret and renders a new key every time. TREK then
# refuses to start rather than lose its secrets; use existingSecret in those setups.
generateEncryptionKey: false
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
# 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,
# 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.
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
# Specify an existing PVC to bind. The other values are then ignored.
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
#
# The startup probe gives a first boot, a long migration or a restore at boot up to ten
# minutes before liveness takes over; liveness alone killed such a boot after about
# 105 s, and the next start began it again. Readiness asks the database, so a pod whose
# database does not answer stops getting traffic without being restarted.
probes:
startup:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 60
liveness:
httpGet:
path: /api/health
port: http
periodSeconds: 30
readiness:
httpGet:
path: /api/health/ready
port: http
periodSeconds: 10
# Further environment variables, for any setting the env block above does not list.
# extraEnv:
# - name: SESSION_DURATION
# value: 7d
extraEnv: []
# Whole ConfigMaps or Secrets:
# extraEnvFrom:
# - secretRef:
# name: trek-smtp
extraEnvFrom: []
resources:
requests:
cpu: 100m
+4
View File
@@ -3,3 +3,7 @@ e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
e2e/help/media.env
+89
View File
@@ -0,0 +1,89 @@
# CLAUDE.md
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit (CI)
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs
npm run lint:pages # enforce the Page pattern (CI gate)
npm run lint:rtl # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
npm run lint:size # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
npm run lint:i18n-keys # every translation key src/ names exists in shared en, and en keys nothing in src/, server/src or plugin-sdk/src reaches may only shrink per en file against scripts/i18n-unused-baseline.json (CI gate; --unused lists them, --update lowers the baseline)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
```
Every baseline ratchet above also fails on an entry that allows more than the tree holds: a count above what its file or rule has now, or a file that is gone. Run the check with `--update` in the change that made it smaller, so a fixed file cannot grow back to its old entry and a new file at a deleted path does not inherit one.
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
## Data flow — offline-first
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. Versions are append-only: a new `this.version(N)` adds its stores to `HISTORY` and a case to `UPGRADE_CASES` in `tests/unit/db/offlineDb.upgrade.test.ts`, which opens the previous version with seeded rows in fake-indexeddb and upgrades it to the current one; a guard there fails on a declared version without both. Never edit a shipped version's stores or `upgrade()`.
- **`src/sync/`**: in `mutationQueue.ts`, offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Every row `enqueue()` writes carries `schemaVersion` (`MUTATION_SCHEMA_VERSION` in `offlineDb.ts`) and `buildVersion`; bump the constant when a row's fields or the way the replay reads them change incompatibly. A flush replays its own format and older ones (an unstamped row is format 1) and skips a row from a newer format without sending, marking or dropping it, holding back the later writes to its entity, so a tab still on an older bundle never replays a shape it does not know. The guard exists only from the release that introduced `schemaVersion` on; a bundle older than that replays every row, so the first bump of `MUTATION_SCHEMA_VERSION` must not ship while a rollback target older than the guard is still in play. A skipped row stays `pending` with no retry or discard path, so on a rolled-back install it holds its entity until the user clears the cache. Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask `mutationQueue.mustQueue()` before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send `X-Base-Updated-At`, so the server refuses the stale replay with a 409 instead. `flush()` takes the Web Lock `trek-mutation-flush:<db name>` so only one tab replays at a time; a tab that finds it taken waits for it, and a `flush()` called while this tab's flush runs or waits for the lock gets that same flush back (with one more pass asked for), so `flush()` never resolves while a tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flush alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal, and especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in `src/` is the layer of its name, so `src/types.ts` follows the `types/` rules. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`.
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop and phone views
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
## Tests
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
+9 -11
View File
@@ -1,8 +1,10 @@
import { test as setup, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
import { E2E_STATE_FILE } from '../playwright.config'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
const stateFile = 'e2e/.tmp/state.json'
const stateFile = E2E_STATE_FILE
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
// seeded admin is created with must_change_password=1, so the first login goes
@@ -27,16 +29,12 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
// support modal, which has NO "OK" button (only CTAs + the X). The shared
// helper handles both notice shapes; dismissal is recorded server-side
// against this user, so clearing it here keeps it cleared for every
// authenticated flow in the run (shared test DB).
await dismissSystemNotices(page, 10_000)
await page.context().storageState({ path: stateFile })
})
+12 -6
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
@@ -7,18 +8,23 @@ import { test, expect } from '@playwright/test'
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
// button (the primary action lives in the footer), so click it explicitly
// rather than pressing Enter. The Create button is the slate primary button;
// Cancel is the bordered one.
const modal = page.locator('.modal-backdrop')
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
// primary action lives in the footer), so click it explicitly rather than pressing
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Trip ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
+114
View File
@@ -0,0 +1,114 @@
/**
* The run's calendar.
*
* The seeded trip ends on the day the pictures are taken, and every other
* date the seed, the fixtures and the guides use is an offset from that day.
* So a run on any date meets a trip that is running, with a last day that is
* today: the boarding pass, What's Next and the forecast all have something
* to show, and nobody has to bump dates in three files every few weeks.
*
* The day is `E2E_PICTURE_DAY` (YYYY-MM-DD), set once by `e2e/help/run.mjs`
* and `playwright.config.ts` so the seed project and every worker agree even
* when the run crosses midnight; without it, today in the machine's zone.
*/
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/
function localToday(): string {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const fromEnv = process.env.E2E_PICTURE_DAY
if (fromEnv && !ISO_DAY.test(fromEnv)) throw new Error(`E2E_PICTURE_DAY must be YYYY-MM-DD, got "${fromEnv}"`)
/** The picture day as YYYY-MM-DD: the trip's last day. */
export const PICTURE_DAY_ISO: string = fromEnv || localToday()
/** Noon UTC on the picture day, a safe anchor for calendar arithmetic. */
function anchor(offset: number): Date {
const d = new Date(`${PICTURE_DAY_ISO}T12:00:00Z`)
d.setUTCDate(d.getUTCDate() + offset)
return d
}
/** `YYYY-MM-DD` of the picture day plus `offset` days. */
export function day(offset: number): string {
return anchor(offset).toISOString().slice(0, 10)
}
/** `YYYY-MM-DDTHH:mm:00`, the shape reservations and expenses take. */
export function at(offset: number, hhmm: string): string {
return `${day(offset)}T${hhmm}:00`
}
/** The calendar year of the picture day plus `years`. */
export function year(years = 0): number {
return anchor(0).getUTCFullYear() + years
}
/** A day `months` months after the picture day, on the given day of the month. */
export function monthsAhead(months: number, dayOfMonth: number): string {
const d = anchor(0)
d.setUTCDate(1)
d.setUTCMonth(d.getUTCMonth() + months)
d.setUTCDate(dayOfMonth)
return d.toISOString().slice(0, 10)
}
const en = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts })
// The labels the English UI prints for a date, for the guides that have to
// find a button by its calendar text.
/** `Sat, Sep 12`, the day filter's chip. */
export function short(offset: number): string {
return en({ weekday: 'short', month: 'short', day: 'numeric' }).format(anchor(offset))
}
/** `Saturday, September 12`, the day filter's long form. */
export function long(offset: number): string {
return en({ weekday: 'long', month: 'long', day: 'numeric' }).format(anchor(offset))
}
/** `September 25, 2026`, a date picker's cell label. */
export function pickerLabel(offset: number): string {
return pickerLabelOn(day(offset))
}
/** The same label for any `YYYY-MM-DD`, e.g. one from `monthsAhead`. */
export function pickerLabelOn(iso: string): string {
return en({ month: 'long', day: 'numeric', year: 'numeric' }).format(new Date(`${iso}T12:00:00Z`))
}
/** `October`: the English name of the month `months` months after the picture day's. */
export function monthName(months: number): string {
return en({ month: 'long' }).format(new Date(`${monthsAhead(months, 1)}T12:00:00Z`))
}
/** `19.09.2026`, what a date input takes when typed. */
export function dotted(offset: number): string {
const [y, m, d] = day(offset).split('-')
return `${d}.${m}.${y}`
}
/** `13th`, for prose that names a day of the month. */
export function ordinal(offset: number): string {
const n = anchor(offset).getUTCDate()
const suffix = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'
return `${n}${suffix}`
}
/** `Wednesday 16 September 2026`, the date line of a booking mail. */
export function emlDate(offset: number): string {
return en({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(anchor(offset)).replace(/,/g, '')
}
/** `Tue, 1 Sep 2026 09:12:00 +0900`, an RFC 2822 mail header for a fixed offset. */
export function mailHeaderDate(offset: number, hhmm: string, tz: string): string {
const d = anchor(offset)
const weekday = en({ weekday: 'short' }).format(d)
const month = en({ month: 'short' }).format(d)
return `${weekday}, ${d.getUTCDate()} ${month} ${d.getUTCFullYear()} ${hhmm}:00 ${tz}`
}
+180
View File
@@ -0,0 +1,180 @@
# Help-center media
Every picture the help center shows is made here, by Playwright, against the real
app. Nothing is drawn or cropped by hand, so a new screen gets pictures in exactly
the same style by following the same path.
## The style, in one place
Everything visual is fixed in `guide.ts` and `promote.mjs`; change it there, never
in a script.
| What | Where | Value |
|---|---|---|
| Viewport | `guide.ts` `VIEWPORT` | 1920 × 1080, device scale 2 (`playwright.config.ts`, project `help-media`) |
| Step picture frame | `guide.ts` `frameFor` | 16:10, at least 960 px wide, 64 px padding around the target, centred on it, never larger than the largest 16:10 frame the viewport holds (1728 × 1080). A target wider than that, a full-width row for instance, is therefore cut at the sides, and that is accepted: the narrow frame is the format of the whole library, and a picture that widens to fit one row is at a different magnification from the one beside it. A target taller than the frame is anchored at its top, so a long section shows its heading |
| Ring around the target | `guide.ts` `ring` | 3 px in the app's `--accent`, 12 px radius, 6 px outside the element, the rest dimmed 30 %. A white hairline goes outside the ring and the badge only where the surface measured behind them has too little contrast against the accent, which is the lightbox and the other near-black overlays |
| The step's number | `guide.ts` `ring` | a 28 px disc in the accent on the ring's top-left corner, outside it. Where that spot is not inside the frame, which is every target wider than the frame, it moves inside far enough to clear the frame's edge. The number is part of the picture; the panel numbers the step again beside it and puts nothing on top of the picture |
| Raster tiles | `guide.ts` `settle` → `showLoadedTiles` | loaded tiles are forced visible before every shot. Leaflet fades a tile in over 200 ms and takes the progress from `+new Date()`, and the clock is pinned, so without this the satellite imagery and every raster basemap are a grey rectangle although each tile came back 200 |
| Drag & drop | `guide.ts` `ringDrag` (a step with `dropTo`) | source ringed, destination ringed dashed, a bowed arrow from the one to the other, both undimmed |
| Hover | `captureGuide` | the target is hovered before the shot (tooltips, hover-only buttons show); `hover` overrides where the centre is wrong |
| Result picture | `guide.media.result` | the full frame after the last step, pointer parked in the corner |
| Hero (screen overview) | `captureHero` | the full frame of the screen as the reader finds it |
| Output | `promote.mjs` | PNG → WebP quality 84; steps 1440 px wide, heroes and results 1920 px; into `public/help-media/<guide-id>/step-<n>.webp`, `result.webp`, `ctx/<screen-id>.webp` |
| No videos | | stills only, by decision |
The app's own appearance is the default theme, English, light mode, the seeded
demo data (`e2e/screenshots/seed.ts`) plus the fixtures in `fixtures.ts`. Do not
change the theme, the language or the zoom for a picture.
## How a screen gets its pictures
1. Register the screen and its guides in `src/help/contexts/<screen>.ts` and
`src/help/registry.ts`; write the English texts as a block in
`shared/src/i18n/en/help.ts` (`// ── Screen: <id>`). Guide ids are global: one id,
one picture directory, so never reuse an id from another screen
(`registry.test.ts` refuses duplicates).
2. Write `e2e/help/<screen>.guide.ts`: one `GuideScript` per guide, exactly one
`StepAction` per registered step, `start` that opens the screen, `cleanup` that
puts the seed back. A step's `target` is the element the text tells the reader
to use; `prepare` gets there, `act` does what the text says.
3. Run `npm run help:media -- <screen>.guide.ts` (own ports 5183/3011, fresh
database, the seed, then the file). Fix until every guide passes; a failing
step is a text that no longer matches the UI.
4. Look at the pictures in `e2e/.tmp/help-media/`, then `node e2e/help/promote.mjs`.
5. Translate the block into every other locale (`i18n:parity:strict` is the gate) and
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
## What the fixtures may look like
The documents and pictures the guides upload are drawn in `fixtures.ts`, because
no real person's paperwork and no photograph may live in this repository. They
still have to look like something a traveller would really put into a trip, or
the picture teaches the reader that the feature is a toy.
- **A document is a document.** `drawPdf` takes an issuer band, a title, a
reference line, a table of labelled fields and a footer, which is the shape of
every confirmation, ticket and voucher. Four lines of Helvetica on white is not
a document; it was the first attempt and it was thrown out.
- **A picture is a graphic, not a painting of a photograph.** A hand-drawn
market or landscape reads as clip art at any size, however it is shaded, and it
was tried three times. What works is the kind of image people actually upload:
a map screenshot with a pin and a search field, a booking confirmation in a
browser window, a printed network map. Those are drawings in reality too, so a
drawing of them is honest.
- **Every date comes from `e2e/dates.ts`**, never from a calendar typed into a
fixture. A confirmation whose check-in is two days before the trip's last day
keeps making sense next month.
- The names of the files are part of the seed: guides find their row by name, so
renaming a fixture means renaming it in the guide too.
## Changing the style
`guide.ts` is shared by every picture, so anything changed there is a property of
the whole library, not of the guide in front of you. Before changing it, work out
which pictures actually come out different and re-render exactly those, one
`--grep` away:
```bash
npm run help:media -- --grep "pictures: (files-trash|files-preview)$|hero: trip-files"
```
The staging directory answers most of those questions without a browser, because
the frame is in the file:
```bash
# which pictures were taken at the frame's maximum, i.e. cut at the sides
python -c "import struct,os;[print(r,f,struct.unpack('>II',open(os.path.join(r,f),'rb').read(24)[16:24])) for r,_,fs in os.walk('e2e/.tmp/help-media') for f in fs if f.endswith('.png')]"
```
A change that alters every picture (the frame, the ring's geometry, the viewport)
is a full run of about four hours on one worker. It is not a thing to do twice,
so look at three or four pictures from a subset run before starting it.
## Rules learned the hard way
- Never run `npx prettier` over `client/` sources: `client/.prettierrc` says
`semi: true` and the sources are written without semicolons, so it rewrites
whole files. Nothing in CI formats the client.
- A killed run leaves its server holding 3011 and 5183, and the next run dies
with "port is already used". Free them first:
`Get-NetTCPConnection -LocalPort 3011 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`.
- The browser's clock is pinned to the picture day (`PICTURE_DAY`). Anything in
the app or a library that animates from the wall clock therefore never
finishes: Leaflet's tile fade is the one that bit, and the fix is in `settle`.
A new picture that comes out empty or grey is worth suspecting of the same.
- `promote.mjs` converts the whole staging directory, so a guide that was not
re-run keeps the picture it had; staging is the source of truth for what gets
published, not the last run.
- Never edit `shared/` while a media run is going: the watcher rebuilds `dist`
mid-run and the pictures show raw i18n keys.
- Never `npm run build --workspace=shared` while `npm run dev` is running; restart
the dev server if it happened.
- Each run boots a fresh server (~2 min); batch fixes, and dump the accessibility
tree once (`page.locator('body').ariaSnapshot()`) instead of guessing locators.
- Several runs at once, each on its own `E2E_WEB_PORT`/`E2E_API_PORT`, share
`server/dist`: build it once and set `E2E_SERVER_PREBUILT=1` on every run, or
each run rebuilds it under the servers of the others. Give each run its own
`TREK_PLUGINS_DIR` and `TREK_PLUGINS_DATA_DIR` as well (the seed installs a
plugin), and its own Vite `cacheDir` when it starts a Vite of its own.
- A plugin installed by a run lives in `server/data/plugins/`; the dev server
imports it on its next restart. Guides that install one uninstall it in `cleanup`.
- The trip's last day is the run day by construction (`e2e/dates.ts`: every
seeded date is an offset from `E2E_PICTURE_DAY`, which `run.mjs` fixes to today
once for the whole run), so the plan opens on today, the trip's last and empty
day; `openTrip` in `trip-shared.ts` selects day 1 and closes its details panel.
A guide that has to find a date by its label builds it with the same helpers
(`short`, `long`, `pickerLabel`, `dotted`) instead of writing a calendar date.
## How the panel shows them
`src/components/Help/HelpGuideView.tsx` and `HelpHome.tsx` hold the other half of
the style, and the two have to agree:
- A step picture goes into a 16:10 box, a hero and a result picture into a 16:9
one, and all three with `object-contain`. Never `object-cover`: the pictures
are already framed, and covering crops them a second time, which took the
sides off every wide one.
- Nothing is drawn over a picture in the panel. The step is numbered beside the
text and the number is in the picture itself.
- A click opens the lightbox, which shows the picture at its own size.
## Before you promote
Look at three or four of the new pictures, not the list of passing tests:
- The ring is in the picture and visible against what is behind it.
- The number is in the picture.
- What the step's text names is in the frame.
- A map shows a map, not a grey rectangle.
- An uploaded document looks like a document someone would upload.
## The guides that show another service
A few guides picture TREK talking to something outside it: AirTrail (flights
into the Transports tab), Dawarich (the recorded route on the map, countries
and wishes in the Atlas), a document store (Nextcloud on the Files tab) and the
booking extractor (confirmations into Bookings and Transports). Their fixtures
in `external.ts` fill each service themselves through its API, so the pictures
do not depend on what happens to be in it; they need its address and key from
`e2e/help/media.env` (copy `media.env.example`, not committed). Without the
file those guides fail loudly at their first step and every other guide runs
as before.
- Every address has to be the machine's LAN address: the server's SSRF guard
refuses loopback whatever `ALLOW_INTERNAL_NETWORK` says, and that flag has to
be `true` in the same file for a private network to be reachable at all.
- AirTrail: any instance with one user and an API key from Settings > Security.
The fixture puts four flights into that account.
- Dawarich: any 1.15 instance with a user and their API key; add the LAN address
to its `APPLICATION_HOSTS`, and give it a reverse geocoder (Nominatim works)
or the Atlas dialog finds no countries. The fixture uploads a synthetic
recording of the trip and of the year before it (`dawarich-track.ts`).
- Nextcloud: an app password. The fixture keeps a folder under `/Reisen` with
two documents, and the guide binds the trip to it.
- The extractor: `KITINERARY_EXTRACTOR_PATH` must point at a program the
server can run with one file argument. On Linux that is the package's binary
(`libkitinerary-bin`); on Windows, a small executable that runs it inside the
TREK Docker image (`docker run --rm -v <dir>:/in:ro --entrypoint
/usr/local/bin/kitinerary-extractor <image> /in/<file>`) and answers
`--version` by itself.
+601
View File
@@ -0,0 +1,601 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureAdminFixtures, pluginPackage } from './fixtures'
import { toggleAddon } from './external'
import { adminContext, adminTabContexts, adminGuides } from '../../src/help/contexts/admin'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the admin screens, keyed by the ids in `src/help/contexts/admin.ts`.
* One route, one tab per screen. The guides that create something (a user, an
* invite link, a backup, an installed plugin) either leave a harmless row behind
* or take it away again in `cleanup`, so the tabs after them look like the seed.
*/
const TAB_OF: Record<string, string> = {
'admin-users': 'Users',
'admin-defaults': 'User Defaults',
'admin-config': 'Personalization',
'admin-settings': 'Settings',
'admin-addons': 'Addons',
'admin-plugins': 'Plugins',
'admin-storage': 'Storage',
'admin-notifications': 'Notifications',
'admin-mcp-tokens': 'MCP Access',
'admin-github': 'GitHub',
'admin-backup': 'Backup',
'admin-audit': 'Audit',
}
const guide = (id: string): HelpGuide => {
const g = adminGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'User Defaults' }) }).first()
/** The nearest card around an element: the admin cards are rounded-xl or rounded-2xl boxes, or a <section>. */
const cardOf = (el: Locator) => el.locator('xpath=ancestor::*[contains(@class,"rounded-xl") or contains(@class,"rounded-2xl") or self::section][1]')
/** An admin card by the text of its h2. */
const section = (page: Page, title: string | RegExp) => cardOf(page.locator('h2').filter({ hasText: title }).first())
/** A provider block under Settings, a <section> headed by an h3. */
const block = (page: Page, title: string) => page.locator('h3').filter({ hasText: new RegExp(`^${title}$`) }).first().locator('xpath=ancestor::section[1]')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A row of the user table by its username. */
const userRow = (page: Page, username: string) => page.locator('table tbody tr').filter({ hasText: username }).first()
/** An addon tile by the addon's name. */
const tile = (page: Page, name: string) => page.locator('h4').filter({ hasText: new RegExp(`^${name}$`) }).first().locator('xpath=ancestor::article[1]')
async function openAdmin(page: Page, tab: string): Promise<void> {
await page.goto('/admin')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
// The run's server is in development mode, which adds a developer-only tab
// nobody has in production; the pictures show the sidebar as shipped.
await sidebar(page).getByRole('button', { name: 'Dev: Notifications' }).evaluateAll(els => {
for (const el of els) (el as HTMLElement).style.display = 'none'
})
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
await settle(page)
}
const opener = (contextId: string) => (page: Page) => openAdmin(page, TAB_OF[contextId])
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
// ── Users ────────────────────────────────────────────────────────────────
'create-user': {
guide: guide('create-user'),
start: opener('admin-users'),
steps: [
{
target: p => section(p, /^Users$/).getByRole('button', { name: 'Create User' }),
act: async p => {
await section(p, /^Users$/).getByRole('button', { name: 'Create User' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Username'), 'lena')
await typeInto(p, modal(p).getByPlaceholder('Email'), 'lena.k@example.com')
await typeInto(p, modal(p).getByPlaceholder('Password'), 'Lena12345!')
await beat(p, 300)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
},
{
target: p => modal(p).getByRole('button', { name: 'Create User' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create User' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(userRow(p, 'lena')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
},
'edit-user': {
guide: guide('edit-user'),
start: opener('admin-users'),
steps: [
{
target: p => userRow(p, 'mara').getByRole('button', { name: 'Edit User' }),
act: async p => {
await userRow(p, 'mara').getByRole('button', { name: 'Edit User' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
'invite-links': {
guide: guide('invite-links'),
start: opener('admin-users'),
steps: [
{
target: p => section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }),
act: async p => {
await section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: '3×', exact: true }).click()
await modal(p).getByRole('button', { name: '14d', exact: true }).click()
await beat(p, 300)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Create & Copy' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
only(p => section(p, 'Invite Links')),
],
},
'delete-user': {
guide: guide('delete-user'),
start: opener('admin-users'),
steps: [
only(p => userRow(p, 'jonas').getByRole('button', { name: 'Delete user' })),
],
},
permissions: {
guide: guide('permissions'),
start: opener('admin-users'),
steps: [
{
prepare: async p => {
const row = section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..')
// The select's trigger is the row's only button; its options are buttons too.
await row.locator('button').first().click()
await p.getByRole('button', { name: 'Admin only', exact: true }).last().click()
await expect(row.getByText('customized')).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..'),
},
{
target: p => section(p, 'Permission Settings').getByRole('button', { name: 'Save', exact: true }),
// Not saved: the seed keeps its defaults for the other screens.
act: async p => {
await section(p, 'Permission Settings').getByRole('button', { name: 'Reset to defaults' }).click()
await settle(p)
},
},
],
},
// ── User Defaults ────────────────────────────────────────────────────────
'default-map': {
guide: guide('default-map'),
start: opener('admin-defaults'),
steps: [
only(p => section(p, /^Map$/)),
{
// The reset link only exists next to a field an admin has set.
prepare: async p => {
await section(p, 'Default User Settings').getByRole('button', { name: 'Dark', exact: true }).click()
// The link sits inside the field's label, so its accessible name is the label's, not "reset".
await expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, 'Default User Settings').locator('label').filter({ hasText: 'Color Mode' }).locator('xpath=..'),
act: async p => {
await section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ }).first().click()
await settle(p)
},
},
],
},
// ── Personalization ──────────────────────────────────────────────────────
'packing-templates': {
guide: guide('packing-templates'),
start: opener('admin-config'),
steps: [
{
target: p => section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }),
act: async p => {
await section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }).click()
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
await expect(input).toBeVisible()
await typeInto(p, input, 'City Break')
await beat(p, 300)
// Leave the seed as it is: the second icon next to the field discards the draft.
await input.locator('xpath=..').locator('button').nth(1).click()
await expect(input).toHaveCount(0)
},
},
{
prepare: async p => {
await p.getByRole('button', { name: 'Beach Holiday', exact: true }).click()
await expect(p.getByRole('button', { name: 'Add category' })).toBeVisible()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=../..').locator('> div').nth(1),
},
only(p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=..')),
],
},
categories: {
guide: guide('categories'),
start: opener('admin-config'),
steps: [
{
prepare: async p => {
await section(p, /^Categories$/).getByRole('button', { name: 'New Category' }).click()
await expect(p.getByPlaceholder('Category name')).toBeVisible()
await typeInto(p, p.getByPlaceholder('Category name'), 'Viewpoints')
await settle(p)
},
// The new category opens in a dialog, its name in the dialog's head.
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
},
},
{
target: p => section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"group")][1]'),
hover: async p => {
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().hover()
},
},
],
},
'school-holiday-catalog': {
guide: guide('school-holiday-catalog'),
start: opener('admin-config'),
steps: [
{
prepare: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Add country' }).click()
await expect(section(p, 'School holidays').getByPlaceholder('US')).toBeVisible()
await settle(p)
},
target: p => section(p, 'School holidays'),
act: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Cancel' }).click()
await expect(section(p, 'School holidays').getByPlaceholder('US')).toHaveCount(0)
},
},
{
prepare: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Bavaria', exact: true }).click()
await expect(modal(p)).toBeVisible()
await expect(modal(p).getByRole('textbox', { name: 'Holiday name' }).first()).toHaveValue('Autumn break', { timeout: 10_000 })
await settle(p)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
// ── Settings ─────────────────────────────────────────────────────────────
'auth-methods': {
guide: guide('auth-methods'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Authentication Methods').getByText('Password Login', { exact: true }).locator('xpath=../..')),
// The SSO rows exist only once OIDC is configured; the run shows where that happens.
only(p => section(p, 'Single Sign-On (OIDC)')),
only(p => section(p, 'Require two-factor authentication (2FA)')),
],
},
oidc: {
guide: guide('oidc'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Single Sign-On (OIDC)')),
only(p => section(p, 'Authentication Methods')),
],
},
'instance-keys': {
guide: guide('instance-keys'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Google Maps API Key')),
{
prepare: async p => {
const details = p.locator('details').filter({ hasText: 'What the key may be used for' })
if (!(await details.evaluate(el => (el as HTMLDetailsElement).open))) await details.locator('summary').click()
await settle(p)
},
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
},
only(p => block(p, 'Unsplash API Key')),
],
},
'places-transit': {
guide: guide('places-transit'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Place search provider')),
only(p => block(p, 'Transit Provider')),
],
},
'file-types': {
guide: guide('file-types'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Allowed File Types')),
],
},
// ── Addons ───────────────────────────────────────────────────────────────
'toggle-addon': {
guide: guide('toggle-addon'),
start: opener('admin-addons'),
steps: [
{
target: p => tile(p, 'Atlas'),
act: async p => {
const toggle = tile(p, 'Atlas').getByRole('button', { name: 'Atlas' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'false', { timeout: 10_000 })
await beat(p, 600)
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
await settle(p)
},
},
only(p => tile(p, 'Lists')),
],
},
'document-providers': {
guide: guide('document-providers'),
start: opener('admin-addons'),
steps: [
// The tile with its shelf: Documents is on in the seed, so the five rows are drawn.
only(p => tile(p, 'Documents')),
{
// The row's switch is a ToggleSwitch whose aria-label is the row's title.
target: p => tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true }),
act: async p => {
const toggle = tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
await settle(p)
},
},
],
// Back to the seed, where every store is off; the files run switches this one on for itself.
cleanup: p => toggleAddon(p.request, 'nextcloud', false),
},
// ── Plugins ──────────────────────────────────────────────────────────────
'install-plugin': {
guide: guide('install-plugin'),
start: async p => {
const upload = await p.request.post('/api/admin/plugins/upload', {
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
})
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`)
await openAdmin(p, 'Plugins')
},
steps: [
{
prepare: async p => {
await p.getByRole('tab', { name: /Discover/ }).click()
await settle(p)
await p.waitForTimeout(1500)
},
target: p => section(p, /^Plugins$/),
act: async p => {
await p.getByRole('tab', { name: /Installed/ }).click()
await settle(p)
},
},
{
target: p => p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]'),
act: async p => {
const row = p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]')
await row.getByRole('button', { name: 'Enable plugin' }).click()
await expect(row.getByText('Active', { exact: true })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
prepare: async p => {
await p.getByTestId('plugin-row-menu-btn-trip-doctor').click()
await expect(p.getByTestId('plugin-row-menu-trip-doctor')).toBeVisible()
await beat(p, 300)
},
target: p => p.getByTestId('plugin-row-menu-trip-doctor'),
// Allowed hosts only shows for plugins that call out; Trip Doctor does not.
hover: async p => { await p.getByTestId('plugin-row-menu-trip-doctor').getByText('View error log').hover() },
act: async p => { await p.keyboard.press('Escape') },
},
],
cleanup: async p => {
const res = await p.request.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } })
if (!res.ok()) throw new Error(`could not uninstall the plugin: ${res.status()} ${await res.text()}`)
},
},
// ── Storage ──────────────────────────────────────────────────────────────
'storage-backends': {
guide: guide('storage-backends'),
start: opener('admin-storage'),
steps: [
{
prepare: async p => {
await section(p, /^Backends$/).getByRole('button', { name: 'Add backend' }).click()
await expect(modal(p).getByText('Add backend', { exact: true })).toBeVisible()
await typeInto(p, modal(p).locator('input').first(), 'archive')
await settle(p)
},
// The new backend opens in a dialog.
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p).getByText('Add backend', { exact: true })).toHaveCount(0)
},
},
only(p => section(p, /^Categories$/)),
only(p => section(p, /^Health$/)),
],
},
// ── Notifications ────────────────────────────────────────────────────────
'channels-instance': {
guide: guide('channels-instance'),
start: opener('admin-notifications'),
steps: [
only(p => section(p, 'Email (SMTP)')),
only(p => section(p, /^Ntfy$/)),
only(p => section(p, 'Trip Reminders')),
],
},
'admin-channels': {
guide: guide('admin-channels'),
start: opener('admin-notifications'),
steps: [
only(p => section(p, 'Admin Ntfy')),
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
],
},
// ── MCP Access ───────────────────────────────────────────────────────────
'mcp-tokens-admin': {
guide: guide('mcp-tokens-admin'),
start: opener('admin-mcp-tokens'),
steps: [
only(p => p.locator('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
],
},
// ── GitHub ───────────────────────────────────────────────────────────────
'release-history': {
guide: guide('release-history'),
start: opener('admin-github'),
steps: [
{
// The banner exists only while a newer release is out; otherwise the card's head.
target: p => {
const banner = p.getByText('Update available', { exact: true })
return banner.or(section(p, 'Release History').locator('> div').first()).first()
},
},
{
prepare: async p => {
await expect(section(p, 'Release History').getByRole('button', { name: 'Show details' }).first()).toBeVisible({ timeout: 20_000 })
},
target: p => section(p, 'Release History'),
},
],
},
// ── Backup ───────────────────────────────────────────────────────────────
'create-backup': {
guide: guide('create-backup'),
start: opener('admin-backup'),
steps: [
{
target: p => section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }),
act: async p => {
await section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }).click()
await expect(section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first()).toBeVisible({ timeout: 120_000 })
await settle(p)
},
},
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first().locator('xpath=ancestor::div[contains(@class,"py-3")][1]')),
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Restore' }).first()),
],
cleanup: async p => {
const list = await p.request.get('/api/backup/list')
const body = (await list.json()) as { backups?: { filename: string }[] } | { filename: string }[]
const backups = Array.isArray(body) ? body : (body.backups ?? [])
for (const b of backups) await p.request.delete(`/api/backup/${encodeURIComponent(b.filename)}`)
},
},
'auto-backup': {
guide: guide('auto-backup'),
start: opener('admin-backup'),
steps: [
{
target: p => section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).locator('xpath=ancestor::label[1]'),
act: async p => {
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
only(p => section(p, 'Auto-Backup').getByText('Delete old backups after', { exact: true }).locator('xpath=..')),
],
cleanup: async p => {
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toHaveCount(0)
await settle(p)
},
},
// ── Audit ────────────────────────────────────────────────────────────────
'audit-log': {
guide: guide('audit-log'),
start: opener('admin-audit'),
steps: [
only(p => cardOf(p.locator('table').first())),
only(p => p.getByRole('button', { name: 'Refresh' })),
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
const ALL_CONTEXTS = [adminContext, ...adminTabContexts]
test.beforeAll(async ({ request }) => {
await ensureAdminFixtures(request)
})
// The invite guide copies the new link; without the permission the write rejects
// and the page logs an unhandled promise, which is noise the pictures do not need.
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
test('every registered admin guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
})
test('hero: admin', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, adminContext.id, p => openAdmin(p, 'Users'))
})
for (const ctx of adminTabContexts) {
test(`hero: ${ctx.id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, ctx.id, opener(ctx.id))
})
}
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+642
View File
@@ -0,0 +1,642 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { year } from '../dates'
import { ensureAtlasFixtures } from './fixtures'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { modal } from './trip-shared'
import { atlasGuides, atlasContext } from '../../src/help/contexts/atlas'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
* They run in the order the context lists them and leave their marks on the
* map on purpose: Greece is marked and removed again, a region of Austria and
* Bavaria stay, Iceland and Petra land on the bucket list. The countries
* are drawn on a canvas, so the way in is always the search box: it flies to
* the country and opens the same popup a click on the map would.
*/
const guide = (id: string): HelpGuide => {
const g = atlasGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
async function openAtlas(page: Page): Promise<void> {
await page.goto('/atlas')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Stats' })).toBeVisible({ timeout: 20_000 })
// The country layer is a canvas; it exists once the borders have arrived.
await expect(page.locator('.leaflet-overlay-pane canvas').first()).toBeVisible({ timeout: 20_000 })
await settle(page)
await page.waitForTimeout(600)
}
/** The search box over the map and its dropdown. */
const searchInput = (page: Page) => page.getByPlaceholder('Search a country...')
const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div[2]')
/** A country in the dropdown (its accessible name starts with the flag's alt text). */
const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) })
/** A geocoded place in the dropdown, under the Places heading. */
const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first()
/**
* The country / region popup: the panel of the shared dialog frame, inside
* the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the
* two are never open at once.
*/
const popup = (page: Page) => modal(page).getByRole('dialog')
/** The glass panel at the bottom. */
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */
const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first()
const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first()
/** The detail card the panel grows for a country picked in the search. */
const detailCard = (page: Page, country: string) =>
panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..')
/**
* Dawarich's own glass panel, left of the statistics: the one panel with its
* name in it. Scoped this way because its two tiles, Wishlist and Countries,
* have namesakes in the dialog's tabs once it is open.
*/
const dawarichPanel = (page: Page) =>
page.locator('div.hidden.md\\:flex').filter({ has: page.getByText('Dawarich', { exact: true }) }).first()
const tile = (page: Page, label: 'Wishlist' | 'Countries') =>
dawarichPanel(page).getByRole('button', { name: label, exact: true })
/** The answers in the dialog: every row is one checkbox named after the country or the wish. */
const rows = (page: Page) => modal(page).getByRole('checkbox')
/** The countries the Atlas paints as visited right now, by code. */
async function visitedCodes(page: Page): Promise<Set<string>> {
const res = await page.request.get('/api/addons/atlas/stats')
const { countries } = (await res.json()) as { countries: { code: string; status: string }[] }
return new Set(countries.filter(c => c.status === 'visited').map(c => c.code))
}
/** What was visited before the countries guide confirmed anything, so `cleanup` takes exactly its additions off again. */
let visitedBefore = new Set<string>()
async function searchCountry(page: Page, name: string): Promise<void> {
const input = searchInput(page)
await input.click()
await input.fill('')
await typeInto(page, input, name)
await expect(countryHit(page, name)).toBeVisible()
}
async function pickCountry(page: Page, name: string): Promise<void> {
await countryHit(page, name).click()
await page.waitForTimeout(900) // fitBounds animates
await settle(page)
}
/**
* Close the country popup with the button in its head band. Not with Escape:
* after a key press the browser draws its focus ring on the next popup, which
* takes the focus as it opens, and that ring would be in the picture.
*/
async function closePopup(page: Page): Promise<void> {
await popup(page).getByRole('button', { name: 'Close', exact: true }).click()
await expect(popup(page)).toHaveCount(0)
}
/**
* The next unset select of the Add place form in the Bucket List tab: month
* and year both read a long dash until picked. The popup's bucket step labels
* its two selects instead, so there they are found by Month and Year.
*/
const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first()
/** Pick an option in one of the month / year selects. */
async function choose(page: Page, trigger: Locator, label: string): Promise<void> {
await trigger.click()
await page.getByRole('button', { name: label, exact: true }).last().click()
await beat(page, 300)
}
/**
* Tag the region path under the middle of the map, so a step can ring it. The
* regions are SVG, but no element carries a name; what is at the centre after
* flying to a country is what the reader would click first anyway.
*/
async function tagCentreRegion(page: Page): Promise<void> {
// The first region request builds the server's admin-1 index; that can take a while.
await expect(page.locator('.leaflet-region-pane path').first()).toBeVisible({ timeout: 90_000 })
const map = await page.locator('.leaflet-container').boundingBox()
if (!map) throw new Error('no map on screen')
const point = { x: map.x + map.width / 2, y: map.y + map.height / 2 }
await page.evaluate(({ x, y }) => {
document.querySelectorAll('[data-help-target]').forEach(el => el.removeAttribute('data-help-target'))
const el = document.elementFromPoint(x, y)
if (!(el instanceof SVGPathElement)) throw new Error(`no region under the map centre, found ${el?.tagName ?? 'nothing'}`)
el.setAttribute('data-help-target', '1')
}, point)
}
const centreRegion = (page: Page) => page.locator('path[data-help-target]')
async function mapCentre(page: Page): Promise<{ x: number; y: number }> {
const map = await page.locator('.leaflet-container').boundingBox()
if (!map) throw new Error('no map on screen')
return { x: map.x + map.width / 2, y: map.y + map.height / 2 }
}
async function hoverMapCentre(page: Page): Promise<void> {
const c = await mapCentre(page)
await page.mouse.move(c.x, c.y)
}
async function clickMapCentre(page: Page): Promise<void> {
const c = await mapCentre(page)
await page.mouse.click(c.x, c.y)
}
type Box = { x: number; y: number; width: number; height: number }
/**
* An invisible box laid over several elements, for a step whose text names
* things that have no parent of their own to ring. The runner rings whatever
* box the target has, so this gives it one. It takes no pointer events, so the
* hover and the click the step makes go to the app underneath, and a step
* that uses it needs its own `hover` for the same reason.
*/
async function spanOver(page: Page, boxes: Box[]): Promise<void> {
await page.evaluate(boxes => {
document.getElementById('trek-help-span')?.remove()
const left = Math.min(...boxes.map(b => b.x))
const top = Math.min(...boxes.map(b => b.y))
const right = Math.max(...boxes.map(b => b.x + b.width))
const bottom = Math.max(...boxes.map(b => b.y + b.height))
const el = document.createElement('div')
el.id = 'trek-help-span'
Object.assign(el.style, {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${right - left}px`,
height: `${bottom - top}px`,
pointerEvents: 'none',
})
document.body.appendChild(el)
}, boxes)
}
const span = (page: Page) => page.locator('#trek-help-span')
async function clearSpan(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-span')?.remove())
}
async function boxOf(locator: Locator): Promise<Box> {
const box = await locator.boundingBox()
if (!box) throw new Error(`no box for ${locator}`)
return box
}
/**
* The name the Atlas shows beside the pointer over a region. It is one fixed
* div the map moves after the pointer, with no role or name of its own; the
* country under the region name is how it is told apart.
*/
async function regionTooltipBox(page: Page, country: string): Promise<Box> {
const box = await page.evaluate(country => {
const tip = Array.from(document.querySelectorAll('div')).find(d =>
d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country))
if (!tip) return null
const r = tip.getBoundingClientRect()
return { x: r.x, y: r.y, width: r.width, height: r.height }
}, country)
if (!box) throw new Error(`no region tooltip naming ${country} on screen`)
return box
}
const SCRIPTS: Record<string, GuideScript> = {
'mark-country': {
guide: guide('mark-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => { await searchCountry(p, 'Greece') },
},
{
target: p => countryHit(p, 'Greece'),
act: async p => {
await pickCountry(p, 'Greece')
await expect(popup(p)).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'unmark-country': {
guide: guide('unmark-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Greece')
await pickCountry(p, 'Greece')
await expect(popup(p).getByText('Remove this country from your visited list?')).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: 'Remove', exact: true }),
act: async p => {
await popup(p).getByRole('button', { name: 'Remove', exact: true }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'country-details': {
guide: guide('country-details'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => { await searchCountry(p, 'Japan') },
},
{
target: p => countryHit(p, 'Japan'),
act: async p => {
await pickCountry(p, 'Japan')
await expect(detailCard(p, 'Japan')).toBeVisible()
// The step promises the card's flag, which is a Twemoji image from a
// CDN. Waited for here so the result shows the flag rather than an
// empty square; should the CDN be out of reach, the card falls back to
// two letters and the image never loads, which fails this step.
const flag = detailCard(p, 'Japan').locator('img[alt="JP"]')
await expect(flag).toBeVisible({ timeout: 15_000 })
await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true)
},
},
],
},
'planned-countries': {
guide: guide('planned-countries'),
start: openAtlas,
steps: [
{
target: p => p.getByRole('button', { name: 'Show planned countries' }).locator('xpath=..'),
act: async p => {
const toggle = p.getByRole('button', { name: 'Show planned countries' })
if ((await toggle.getAttribute('aria-pressed')) !== 'true') await toggle.click()
await settle(p)
},
},
{
prepare: async p => {
await searchCountry(p, 'Portugal')
await pickCountry(p, 'Portugal')
await expect(detailCard(p, 'Portugal')).toBeVisible()
},
target: p => detailCard(p, 'Portugal'),
},
],
},
regions: {
guide: guide('regions'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Austria')
await pickCountry(p, 'Austria')
// An unvisited country opens its popup on arrival; the regions are the point here.
await expect(popup(p)).toBeVisible()
await closePopup(p)
await tagCentreRegion(p)
},
},
{
// The text says hovering names the region. That name floats right of
// the pointer and reaches past the region's own box, so a ring round
// the region alone ran straight through it. The ring goes round both.
prepare: async p => {
await hoverMapCentre(p)
await settle(p)
await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')])
},
target: span,
hover: hoverMapCentre,
act: async p => {
await clearSpan(p)
await clickMapCentre(p)
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'search-place': {
guide: guide('search-place'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
const input = searchInput(p)
await input.click()
await typeInto(p, input, 'Munich')
await expect(placeHit(p, 'Munich, Bavaria')).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => placeHit(p, 'Munich, Bavaria'),
act: async p => {
await placeHit(p, 'Munich, Bavaria').click()
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'bucket-country': {
guide: guide('bucket-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Iceland')
await pickCountry(p, 'Iceland')
await expect(popup(p)).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Add to bucket list/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Add to bucket list/ }).click()
await expect(popup(p).getByText('When do you plan to visit?')).toBeVisible()
},
},
{
prepare: async p => {
await choose(p, popup(p).getByLabel('Month', { exact: true }), 'June')
await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1)))
},
// The whole popup: the step names both selects and the button under them.
target: p => popup(p),
act: async p => {
await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
await p.getByRole('button', { name: 'Bucket List' }).click()
await settle(p)
},
},
],
},
'bucket-place': {
guide: guide('bucket-place'),
start: openAtlas,
steps: [
{
target: p => p.getByRole('button', { name: 'Bucket List' }),
act: async p => { await p.getByRole('button', { name: 'Bucket List' }).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Add place' }),
act: async p => { await p.getByRole('button', { name: 'Add place' }).click() },
},
{
// The row, not the field alone: the text has the reader press the
// search button beside it as well.
target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'),
act: async p => {
const input = p.getByPlaceholder('Name (country, city, place...)')
await typeInto(p, input, 'Petra')
await input.locator('xpath=following-sibling::button[1]').click()
// The geocoder also knows a Petra in Mallorca and a few streets; the one in Jordan is the wish.
const hit = p.locator('body > div.bg-surface-card button').filter({ hasText: 'Jordan' }).first()
await expect(hit).toBeVisible({ timeout: 15_000 })
await hit.click()
await expect(input).toHaveValue(/petra/i)
},
},
{
prepare: async p => {
await choose(p, unsetSelect(panel(p)), 'Sep')
await choose(p, unsetSelect(panel(p)), String(year(1)))
},
// The whole form: framed on the small Add button alone, the picture lost
// the name and the month, and looked like Add on an empty form.
target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'),
hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(),
act: async p => {
await p.getByRole('button', { name: 'Add', exact: true }).click()
await expect(panel(p).getByText('Petra').first()).toBeVisible()
await settle(p)
},
},
],
},
stats: {
guide: guide('stats'),
start: openAtlas,
steps: [
{ target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() },
{
// The text covers the continents, the streak and this year's trips.
// Those are two groups side by side with no box of their own around
// both, so the ring gets one laid over them.
prepare: async p => {
await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))])
},
target: span,
hover: p => continentStats(p).hover(),
act: clearSpan,
},
],
},
'dawarich-countries': {
guide: guide('dawarich-countries'),
start: async p => {
await openAtlas(p)
visitedBefore = await visitedCodes(p)
},
steps: [
{
target: p => tile(p, 'Countries'),
act: async p => {
await tile(p, 'Countries').click()
await expect(modal(p).getByRole('button', { name: 'Look for countries' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The pitch with its button rather than the button alone: a frame
// centred on the button cut off the dialog's head band, and with it the
// name of the service this dialog asks.
target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'),
hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: 'Look for countries' }).click()
// A year in thirteen 30-day reads, one after another, each of which
// Dawarich computes on the spot; the client gives it four minutes.
// An empty answer shows a sentence instead of rows and fails here,
// which is right: a dialog with nothing to add is no picture.
await expect(rows(p).first()).toBeVisible({ timeout: 240_000 })
await settle(p)
// The flags come from a CDN and land after the rows.
await p.waitForTimeout(800)
},
},
{
// The whole dialog: the list step 2 describes (flag, cities, ticked
// rows) is only ever pictured here, and a frame centred on the button
// cut the rows in half. The button sits in the footer, in the ring too.
target: p => modal(p).getByRole('dialog'),
hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click()
await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 })
// Closed with the button in its head band, not Escape: the focus goes
// back to the Countries tile, and after a key press the browser draws
// its focus ring there, into the result picture.
await modal(p).getByRole('button', { name: 'Close', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
// The Atlas re-reads itself after a write; the colours land with the answer.
await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size)
await settle(p)
await p.waitForTimeout(900)
},
},
],
cleanup: async p => {
// Off the map again, so the next run and the guides after this one start
// from the seed. Unmarking tombstones a country; confirming it again from
// the dialog lifts that, so a re-run offers the same rows.
const after = await visitedCodes(p)
for (const code of after) {
if (!visitedBefore.has(code)) await p.request.delete(`/api/addons/atlas/country/${code}/mark`)
}
},
},
'dawarich-wishes': {
guide: guide('dawarich-wishes'),
start: openAtlas,
steps: [
{
target: p => tile(p, 'Wishlist'),
act: async p => {
await tile(p, 'Wishlist').click()
await expect(modal(p).getByRole('button', { name: 'Check wishlist' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// As in the countries guide: the pitch with its button, so the head band
// stays in the frame.
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'),
hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: 'Check wishlist' }).click()
// One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra),
// fifteen seconds each at worst; New Zealand and Iceland have none and
// are counted in the note instead.
await expect(rows(p).first()).toBeVisible({ timeout: 120_000 })
await settle(p)
},
},
{
// The whole dialog, as in the countries guide: the rows with distance,
// stay and day and the note with the 250 m rule are what step 2 quotes.
target: p => modal(p).getByRole('dialog'),
hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click()
await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 })
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
// The result is the list itself: the ticked date under each wish is a
// button labelled Undo, there once the list has re-read itself.
await p.getByRole('button', { name: 'Bucket List' }).click()
await expect(panel(p).getByRole('button', { name: 'Undo' }).first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
// Every tick that came from a recording goes back, by the route the Undo
// button uses, so the wishes are open again for the next run.
const res = await p.request.get('/api/addons/atlas/bucket-list')
const { items } = (await res.json()) as { items: { id: number; visited_source: string | null }[] }
for (const item of items) {
if (item.visited_source === 'dawarich') await p.request.delete(`/api/integrations/dawarich/bucket-list/${item.id}/visit`)
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureAtlasFixtures(request)
// After the Atlas fixtures: the connection adds two wishes with coordinates
// (Fushimi Inari Taisha, Wadi Rum) to the list the fixtures create.
await ensureDawarichConnection(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide: while the addon is on, every Atlas carries the Dawarich
// panel left of Stats. This file's pictures show it on purpose; the files
// after it do not, so it goes off again here, whatever happened in between.
await disconnectDawarich(request)
})
test('every registered atlas guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(atlasContext.guides.slice().sort())
})
test('hero: atlas', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, atlasContext.id, openAtlas)
})
for (const id of atlasContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
// The Dawarich reads are a year of countries in thirteen pieces and one
// look-up per wish: more waiting on another service than four minutes
// may hold on a slow instance.
if (id.startsWith('dawarich-')) test.slow()
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+790
View File
@@ -0,0 +1,790 @@
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
import path from 'node:path'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
import { ensureExtraTrips } from './fixtures'
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Collections guides, keyed by the ids in
* `src/help/contexts/collections.ts`. The seed leaves one list, "Kyoto
* shortlist", with the Japan trip's three Kyoto places on it; the guides add a
* second list, a Kyoto place found by search, three more Kyoto temples taken
* from the trip, a label, a member and a file round trip, in the order the
* context lists them, and leave the list side of it in place. Whatever they put
* into a trip comes out again in that guide's cleanup.
*/
const KYOTO = 'Kyoto shortlist'
const LISBON = 'Lisbon coffee'
const JAPAN = 'Autumn in Japan'
/**
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
* holds Tokyo and the three Kyoto places the list already has, so an import from
* it could only add Tokyo to a list called "Kyoto shortlist", and every later
* picture would carry that. None of these is on a day, which is also what the
* import's third step says starts out selected.
*/
const KYOTO_EXTRAS = [
{ name: 'Kinkaku-ji Temple', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
description: 'The Golden Pavilion, mirrored in its pond.' },
{ name: 'Tenryu-ji Temple', lat: 35.0158, lng: 135.6737, address: '68 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto',
description: 'Zen garden next to the bamboo grove.' },
{ name: 'Kiyomizu-dera Temple', lat: 34.9949, lng: 135.785, address: '1-294 Kiyomizu, Higashiyama Ward, Kyoto',
description: 'The wooden stage over the maples.' },
]
/** The two places in Arashiyama, which the labels guide groups under that name. */
const ARASHIYAMA_PLACES = ['Arashiyama Bamboo Grove', 'Tenryu-ji Temple']
const guide = (id: string): HelpGuide => {
const g = collectionsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const rail = (page: Page) => page.locator('.col-rail')
const listRow = (page: Page, name: string) => rail(page).locator('button.col-row-btn').filter({ has: page.locator('.nm').getByText(name, { exact: true }) })
const hero = (page: Page) => page.locator('.col-hero')
const filterBar = (page: Page) => page.locator('.col-filterbar')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** The rounded panel inside the backdrop, for framing a whole dialog. */
const modalCard = (page: Page) => modal(page).locator('> [role="dialog"]').first()
/** A trip's tile on the first step of the import from a trip. */
const importTrip = (page: Page, title: string) => modal(page).getByRole('button').filter({ hasText: title })
/**
* The trip tiles on the first step of that import, and the rows of a trip's
* places on its second step: each is the dialog's only staggered block while it
* is on screen.
*/
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
const placeRows = (page: Page) => page.locator('.col-lrow')
const selectionBar = (page: Page) => page.locator('.col-selbar')
/** The detail sheet beside the list. */
const detail = (page: Page) => page.locator('.col-detail').first()
/** The map beside the list, with its floating controls. */
const mapShell = (page: Page) => page.locator('.col-map-shell')
/** The map's search pill: the magnifier and the field together. */
const mapSearch = (page: Page) => mapShell(page).locator('.col-map-search').first()
// ── Trip data over the API ────────────────────────────────────────────────────
async function tripIdOf(page: Page, title: string): Promise<number> {
const res = await page.request.get('/api/trips')
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
const trips = Array.isArray(body) ? body : (body.trips ?? [])
const trip = trips.find(t => t.title === title)
if (!trip) throw new Error(`trip "${title}" is missing`)
return trip.id
}
async function tripPlaces(page: Page, tripId: number): Promise<{ id: number; name: string }[]> {
const res = await page.request.get(`/api/trips/${tripId}/places`)
if (!res.ok()) throw new Error(`GET places of trip ${tripId}: ${res.status()}`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
return Array.isArray(body) ? body : (body.places ?? [])
}
async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Promise<void> {
for (const id of ids) {
const res = await page.request.delete(`/api/trips/${tripId}/places/${id}`)
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE place ${id} of trip ${tripId}: ${res.status()}`)
}
}
/** The places the import guide put into the Japan trip, removed again in its cleanup. */
let addedToTrip: number[] = []
/** The Japan trip's places before the copy guide ran; everything else is the copy's. */
let placesBeforeCopy: Set<number> | null = null
// ── Waiting for the page to look finished ─────────────────────────────────────
/**
* Take focus off whatever holds it. A focused field or a button that got focus
* back from a closed dialog draws an outline in the accent, which in a picture
* reads as a second step ring.
*/
async function blurFocus(page: Page): Promise<void> {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.())
}
/**
* Let a Leaflet camera move that is under way arrive.
*
* Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to
* the picture day (guide.ts), so a pan started for a picture never gets past its
* first frame: the map stays where it was and the place the text says it pans
* to is not in view. Moving the fixed time on for a moment lets the running
* animation see that its time is up and finish, exactly where it would have
* ended; the clock goes back to the picture day straight after.
*/
async function finishMapMotion(page: Page): Promise<void> {
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
await page.clock.setFixedTime(PICTURE_DAY)
}
/**
* Wait until the map has stopped moving and shows its pins in the part of it
* that is on screen: every pin, or with `every: false` at least one, for a map
* that has just panned to one place and may have left others behind its edge.
* Nothing to wait for where the list has no map.
*/
async function mapReady(page: Page, { every = true } = {}): Promise<void> {
if (!(await mapShell(page).count())) return
await finishMapMotion(page)
await expect
.poll(
() =>
mapShell(page).evaluate((el, every) => {
if (el.querySelector('.leaflet-zoom-anim, .leaflet-pan-anim, .leaflet-cluster-anim')) return 'moving'
const map = el.getBoundingClientRect()
const box = {
left: Math.max(map.left, 0),
top: Math.max(map.top, 0),
right: Math.min(map.right, window.innerWidth),
bottom: Math.min(map.bottom, window.innerHeight),
}
const icons = Array.from(el.querySelectorAll('.leaflet-marker-icon')).map(i => i.getBoundingClientRect())
const inside = icons.filter(r => r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom).length
if (!inside) return 'no pins in view'
return every && inside < icons.length ? `${icons.length - inside} of ${icons.length} pins out of view` : 'ready'
}, every, { timeout: 2_000 }), // short: a map on its way off the page is asked again, not waited for
{ message: 'the map shows the pins of the list', timeout: 15_000 },
)
.toBe('ready')
}
/**
* Frame the map on the list that is open.
*
* The map opens on a camera worked out for the whole window, not for the pane it
* sits in (MapView's opening frame), so on this page it starts about one zoom
* level too close and every pin is off its edges. It frames the pane properly
* whenever the number of places on it changes, so the search pill is used to
* empty it and fill it again, which is what a reader who searches and clears the
* search sees. The pill ends empty and the page as it was.
*/
async function frameMap(page: Page): Promise<void> {
if (!(await mapShell(page).count())) return
const field = mapSearch(page).locator('input')
const rows = await placeRows(page).count()
await field.fill('zzz')
await expect(placeRows(page)).toHaveCount(0)
await field.fill('')
await expect(placeRows(page)).toHaveCount(rows)
await blurFocus(page)
await mapReady(page)
}
/** Photo requests of each page still in flight: the lookups and the images they point at. */
const photoRequests = new WeakMap<Page, Set<Request>>()
function trackPhotos(page: Page): void {
if (photoRequests.has(page)) return
const pending = new Set<Request>()
photoRequests.set(page, pending)
const isPhoto = (r: Request) => r.url().includes('/api/maps/place-photo/')
page.on('request', r => { if (isPhoto(r)) pending.add(r) })
page.on('requestfinished', r => pending.delete(r))
page.on('requestfailed', r => pending.delete(r))
// A cached photo whose file has gone answers with an empty 204, and the avatar
// falls back to its pin. Say so in the log, because that is not the app.
page.on('response', r => {
if (isPhoto(r) && r.url().endsWith('/bytes') && r.status() === 204) console.warn(`[collections] empty place photo: ${r.url()}`)
})
}
/**
* Wait for the place photos. Each avatar looks its photo up first and loads the
* image after that, so the lookups can be quiet for a moment while an image is
* still to come; quiet has to hold for a while before it counts. Bounded, so a
* lookup that hangs costs time, not the run.
*/
async function photosReady(page: Page): Promise<void> {
const pending = photoRequests.get(page)
if (pending) {
const until = Date.now() + 30_000
let quietSince = 0
while (Date.now() < until) {
if (pending.size) quietSince = 0
else if (!quietSince) quietSince = Date.now()
else if (Date.now() - quietSince > 800) break
await page.waitForTimeout(100)
}
if (pending.size) console.warn(`[collections] place photos still loading: ${[...pending].map(r => r.url()).join(', ')}`)
}
await page
.waitForFunction(() => Array.from(document.querySelectorAll<HTMLImageElement>('.col-lrow img')).every(img => img.complete), undefined, { timeout: 10_000 })
.catch(() => {})
}
/** Map framed, pins drawn, photos in, nothing focused: a page ready for its picture. */
async function pageReady(page: Page, { reframe = false, every = true } = {}): Promise<void> {
if (reframe) await frameMap(page)
else await mapReady(page, { every })
await photosReady(page)
await blurFocus(page)
await settle(page)
}
async function openCollections(page: Page): Promise<void> {
trackPhotos(page)
await page.goto('/collections')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(rail(page)).toBeVisible({ timeout: 20_000 })
await expect(listRow(page, KYOTO)).toBeVisible({ timeout: 20_000 })
// All saved, which this lands on, always has places, and the map comes with them.
await expect(placeRows(page).first()).toBeVisible({ timeout: 20_000 })
await pageReady(page, { reframe: true })
}
async function openList(page: Page, name: string): Promise<void> {
await openCollections(page)
await listRow(page, name).click()
await expect(hero(page)).toContainText(name)
// Until its places are in, the page can still show the map of the list before.
await settle(page)
await expect(page.locator('.col-loading')).toHaveCount(0)
await pageReady(page, { reframe: true })
}
/** Turn on select mode and tick the named places. */
async function selectPlaces(page: Page, names: string[]): Promise<void> {
await filterBar(page).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(page)).toBeVisible()
for (const name of names) await placeRow(page, name).click()
await expect(selectionBar(page)).toContainText(`${names.length} selected`)
}
/** Pick an option in a CustomSelect that has a search box. */
async function pickSearchable(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click()
}
/** The place the add guide finds by search: not on the list yet, with a real street address. */
const SEARCH_FOR = 'Nijo Castle'
const FOUND = /Nij[oō]/
const SCRIPTS: Record<string, GuideScript> = {
'create-list': {
guide: guide('create-list'),
start: openCollections,
steps: [
{
target: p => rail(p).getByRole('button', { name: 'New list' }),
act: async p => {
await rail(p).getByRole('button', { name: 'New list' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: modalCard,
act: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Tokyo 2025'), LISBON)
await modal(p).getByRole('button', { name: '#f97316' }).click()
await typeInto(p, modal(p).getByPlaceholder('Add a description…'), 'Pastéis, miradouros and the best flat white in Alfama.')
await beat(p, 400)
// The description keeps focus after typing, and its focus border sits
// right next to the ring on Create in the next picture.
await blurFocus(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(hero(p)).toContainText(LISBON)
// The new list is empty: its two ways to fill it replace the list and the map.
await expect(p.locator('.col-cta').first()).toBeVisible()
await expect(mapShell(p)).toHaveCount(0)
await pageReady(p)
},
},
],
},
'add-place': {
guide: guide('add-place'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Add a place' }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Add a place' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
// The whole pill, magnifier included, not the bare input inside it.
target: p => modal(p).getByPlaceholder('Search for a place…').locator('xpath=..'),
act: async p => {
const search = modal(p).getByPlaceholder('Search for a place…')
await typeInto(p, search, SEARCH_FOR)
await modal(p).getByRole('button', { name: 'Search', exact: true }).click()
// The OpenStreetMap hit, whose address runs from the street to the
// country; the index can answer the same name with only a ward.
const hit = modal(p).locator('button').filter({ hasText: FOUND }).filter({ hasText: /Kyoto.*Japan/ }).first()
await expect(hit).toBeVisible({ timeout: 20_000 })
await hit.click()
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(FOUND)
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(placeRow(p, 'Nij')).toBeVisible({ timeout: 15_000 })
// The dialog stays open for the next place; the guide is done with it.
await beat(p, 600)
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
await pageReady(p)
},
},
],
},
'import-from-trip': {
guide: guide('import-from-trip'),
start: async p => {
const tripId = await tripIdOf(p, JAPAN)
const have = new Set((await tripPlaces(p, tripId)).map(x => x.name))
addedToTrip = []
for (const place of KYOTO_EXTRAS) {
if (have.has(place.name)) continue
const res = await p.request.post(`/api/trips/${tripId}/places`, { data: place })
if (!res.ok()) throw new Error(`could not add "${place.name}" to ${JAPAN}: ${res.status()} ${await res.text()}`)
const body = (await res.json()) as { place?: { id: number }; id?: number }
const id = body.place?.id ?? body.id
if (id != null) addedToTrip.push(id)
}
await openList(p, KYOTO)
},
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Import from a trip' }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Import from a trip' }).click()
await expect(modal(p)).toBeVisible()
await expect(importTrip(p, JAPAN)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// All the trip tiles, not the one: a tile in the left column puts the
// frame's centre there, and the dialog's right half falls off the picture.
target: importStagger,
act: async p => {
await importTrip(p, JAPAN).click()
await expect(modal(p).getByPlaceholder('Search places…')).toBeVisible()
await expect(importStagger(p).getByRole('button').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole dialog: the rows with their toolbar are taller than the
// smallest frame, so a frame centred on them cuts the head band off.
target: modalCard,
act: async p => {
// Nothing to tick: the three temples no day holds are what the dialog
// selects by itself, which is what the step's text says.
for (const place of KYOTO_EXTRAS) {
await expect(importStagger(p).getByRole('button', { name: new RegExp(place.name) })).toHaveAttribute('aria-pressed', 'true')
}
await expect(modal(p).getByRole('button', { name: `Import ${KYOTO_EXTRAS.length}` })).toBeVisible()
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: /^Import \d+$/ }),
act: async p => {
await modal(p).getByRole('button', { name: /^Import \d+$/ }).click()
await expect(modal(p).getByText('Import finished')).toBeVisible({ timeout: 15_000 })
// The head band has a Close of its own; the one meant here ends the dialog in its footer.
await modal(p).locator('footer').getByRole('button', { name: 'Close' }).click()
await expect(modal(p)).toHaveCount(0)
for (const place of KYOTO_EXTRAS) await expect(placeRow(p, place.name)).toBeVisible()
await pageReady(p)
},
},
],
cleanup: async p => {
// The list keeps its copies; the trip goes back to what the seed made of it.
await deleteTripPlaces(p, await tripIdOf(p, JAPAN), addedToTrip)
addedToTrip = []
},
},
'place-status': {
guide: guide('place-status'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => placeRows(p).first().getByRole('button', { name: 'Idea' }),
act: async p => {
await placeRows(p).first().getByRole('button', { name: 'Idea' }).click()
await expect(placeRows(p).first().getByRole('button', { name: 'Want to go' })).toBeVisible()
await settle(p)
},
},
{
target: p => placeRows(p).first().getByRole('button', { name: 'Want to go' }),
act: async p => {
await placeRows(p).first().getByRole('button', { name: 'Want to go' }).click()
await expect(placeRows(p).first().getByRole('button', { name: 'Visited' })).toBeVisible()
await pageReady(p)
},
},
],
},
'place-detail': {
guide: guide('place-detail'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => placeRows(p).nth(1),
act: async p => {
await placeRows(p).nth(1).click()
await expect(detail(p)).toBeVisible()
// The map pans to the place; let that pan arrive before the next picture.
// Places at the far side of the list can end up past the map's edge.
await pageReady(p, { every: false })
},
},
{
target: p => detail(p).locator('.col-detail-footer'),
},
],
},
labels: {
guide: guide('labels'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first(),
act: async p => {
await p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first().click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByPlaceholder('e.g. Berlin').locator('xpath=../..'),
act: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Berlin'), 'Arashiyama')
await modal(p).getByRole('button', { name: 'Add label' }).click()
await expect(modal(p).locator('input[aria-label="Label name"][value="Arashiyama"]')).toBeVisible()
await beat(p, 400)
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
// Escape hands focus back to the label control, outlined.
await blurFocus(p)
await settle(p)
},
},
{
// The two places that are in Arashiyama, so the label means what it says.
prepare: p => selectPlaces(p, ARASHIYAMA_PLACES),
target: p => selectionBar(p).getByRole('button', { name: 'Assign label' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Assign label' }).click()
await expect(modal(p)).toBeVisible()
await modal(p).getByRole('button', { name: 'Arashiyama' }).click()
await modal(p).getByRole('button', { name: 'Assign label' }).click()
await expect(modal(p)).toHaveCount(0)
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toHaveCount(0)
for (const name of ARASHIYAMA_PLACES) await expect(placeRow(p, name).locator('.col-lrow-label')).toContainText('Arashiyama')
await blurFocus(p)
await settle(p)
},
},
{
target: p => p.locator('.col-labelfilter').first(),
act: async p => {
await p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ }).click()
await expect(placeRows(p)).toHaveCount(ARASHIYAMA_PLACES.length)
await pageReady(p)
},
},
],
cleanup: async p => {
// Leave the list unfiltered for the guides that follow.
const chip = p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ })
if ((await chip.count()) && (await chip.getAttribute('aria-pressed')) === 'true') await chip.click()
},
},
'filter-select': {
guide: guide('filter-select'),
start: p => openList(p, KYOTO),
steps: [
{
prepare: async p => {
await filterBar(p).locator('button[aria-haspopup="listbox"]').first().click()
await expect(filterBar(p).locator('[role="listbox"]')).toBeVisible()
},
target: p => filterBar(p).locator('button[aria-haspopup="listbox"]').first(),
act: async p => {
await filterBar(p).locator('[role="option"]').first().click()
await expect(filterBar(p).locator('[role="listbox"]')).toHaveCount(0)
},
},
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await selectionBar(p).getByRole('button', { name: 'Select all' }).click()
await blurFocus(p)
},
target: selectionBar,
act: pageReady,
},
],
cleanup: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toHaveCount(0)
},
},
'copy-to-trip': {
guide: guide('copy-to-trip'),
start: async p => {
placesBeforeCopy = new Set((await tripPlaces(p, await tripIdOf(p, JAPAN))).map(x => x.id))
await openList(p, KYOTO)
},
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
// Two places the Japan trip does not have yet: the one found by search
// and a temple whose trip copy the import guide took out again.
await selectPlaces(p, ['Nij', 'Kinkaku-ji Temple'])
await beat(p, 300)
},
},
{
target: p => selectionBar(p).getByRole('button', { name: 'Copy to trip' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Copy to trip' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('button').filter({ hasText: JAPAN }),
act: async p => {
await modal(p).locator('button').filter({ hasText: JAPAN }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await pageReady(p)
},
},
],
cleanup: async p => {
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
// The copies leave the trip again, so the trip screens keep the seed's trip.
if (placesBeforeCopy) {
const tripId = await tripIdOf(p, JAPAN)
const before = placesBeforeCopy
await deleteTripPlaces(p, tripId, (await tripPlaces(p, tripId)).filter(x => !before.has(x.id)).map(x => x.id))
placesBeforeCopy = null
}
},
},
'share-list': {
guide: guide('share-list'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Share' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Share' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
// The invite row: the user picker, the role picker and Send invite. Each
// picker sits in a wrapper that sizes it, so the row is three levels up.
target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../../..'),
act: async p => {
await pickSearchable(p, modal(p).getByRole('button', { name: 'Select a user' }), 'mira')
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Send invite' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Send invite' }).click()
await expect(modal(p).getByText('pending invite')).toBeVisible({ timeout: 10_000 })
await beat(p, 500)
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
// Escape gives focus back to Share, and its outline would read as a ring.
await pageReady(p)
},
},
],
},
'export-list': {
guide: guide('export-list'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Export' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Export' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
{
target: p => p.getByRole('menu'),
act: async p => {
const download = p.waitForEvent('download')
await p.getByRole('menuitem', { name: /TREK list/ }).click()
const file = await download
exportedFile = path.join(OUT_DIR, 'kyoto.trekcollection.json')
await file.saveAs(exportedFile)
await settle(p)
},
},
],
},
'import-file': {
guide: guide('import-file'),
start: openCollections,
steps: [
{
target: p => rail(p).getByRole('button', { name: 'Import a list from a file' }),
act: async p => {
await rail(p).getByRole('button', { name: 'Import a list from a file' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Choose a list file' }),
act: async p => {
if (!exportedFile) throw new Error('the export guide did not leave a file to import')
await modal(p).locator('input[type="file"]').setInputFiles(exportedFile)
await expect(modal(p).getByRole('button', { name: /^Import$/ })).toBeEnabled({ timeout: 10_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'New list' }).locator('xpath=..'),
act: async p => {
const name = modal(p).locator('input:not([type="file"])').last()
await name.fill('')
await typeInto(p, name, 'Kyoto shortlist (from file)')
await modal(p).getByRole('button', { name: /^Import$/ }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await expect(hero(p)).toContainText('Kyoto shortlist (from file)')
// A new list mounts a new map, which opens on the window's camera.
await pageReady(p, { reframe: true })
},
},
],
},
'edit-list': {
guide: guide('edit-list'),
start: p => openList(p, LISBON),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Edit' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: modalCard,
act: async p => {
const desc = modal(p).getByPlaceholder('Add a description…')
await desc.fill('')
await typeInto(p, desc, 'Coffee first, then the miradouros. For the long weekend in Lisbon.')
await modal(p).getByRole('button', { name: 'Save' }).click()
await expect(modal(p)).toHaveCount(0)
await pageReady(p)
},
},
],
},
'all-saved': {
guide: guide('all-saved'),
start: openCollections,
steps: [
{
target: p => rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }),
act: async p => {
await rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }).click()
await expect(hero(p)).toContainText('All saved')
await pageReady(p)
},
},
{
// The whole pill, magnifier included, not the bare input inside it.
target: mapSearch,
act: async p => {
await typeInto(p, mapSearch(p).locator('input'), 'temple')
await settle(p)
},
},
],
},
}
/** The list file the export guide downloads and the import guide reads back. */
let exportedFile: string | null = null
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
})
test('every registered collections guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(collectionsContext.guides.slice().sort())
})
test('hero: collections', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, collectionsContext.id, p => openList(p, KYOTO))
})
for (const id of collectionsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+421
View File
@@ -0,0 +1,421 @@
import { test, expect, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { monthName, monthsAhead, pickerLabelOn } from '../dates'
import { ensureExtraTrips, coverFixture, card, EXTRA_TRIPS } from './fixtures'
import { dashboardGuides, dashboardContext } from '../../src/help/contexts/dashboard'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the dashboard guides. Each script is keyed by the guide id from
* `src/help/contexts/dashboard.ts` and has exactly one action per registered
* step; the runner refuses a mismatch, so a step added to the registry without
* an action here fails this run instead of shipping a raw key.
*/
const LISBON = EXTRA_TRIPS[0].title
const NORWAY = EXTRA_TRIPS[1].title
const guide = (id: string): HelpGuide => {
const g = dashboardGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
async function openDashboard(page: Page): Promise<void> {
await page.goto('/dashboard')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.locator('.hero-trip')).toBeVisible()
await expect(card(page, LISBON)).toBeVisible()
}
/** The trip form, scoped so the cover-search inputs above the title cannot be matched by accident. */
const modal = (page: Page) => page.locator('.trek-modal-backdrop')
/** The topmost dialog of any kind (confirm, copy, calendar): they all share the backdrop class. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
const titleField = (page: Page) => modal(page).getByPlaceholder('e.g. Summer in Japan')
/**
* A date picker's trigger by the label above it. The trigger names itself after
* its value once one is set (and the end date is filled in the moment the start
* is picked), so "End Date" as a button name only works while it is empty.
*/
const datePicker = (page: Page, label: string) =>
modal(page).getByText(label, { exact: true }).locator('xpath=..').getByRole('button').first()
/** Pick a date in the calendar popup: open it, go to the month, click the day. */
async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, monthsAhead: number, dayLabel: string): Promise<void> {
await trigger.click()
for (let i = 0; i < monthsAhead; i++) {
await page.getByRole('button', { name: 'Next month' }).click()
await beat(page, 350)
}
await page.getByRole('button', { name: dayLabel, exact: true }).click()
}
/** Hover-revealed action on a card. */
const cardAction = (page: Page, title: string, name: string) => card(page, title).locator(`.trip-action-btn[aria-label="${name}"]`)
/** The trip the create guide makes, eight months out, named after the month it lands in. */
const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
/** The trip search above the cards (#2190), by its accessible name. */
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
const JAPAN = 'Autumn in Japan'
const SCRIPTS: Record<string, GuideScript> = {
'search-trips': {
guide: guide('search-trips'),
start: openDashboard,
steps: [
{
target: p => tripSearch(p).locator('xpath=..'),
act: async p => {
await typeInto(p, tripSearch(p), 'Nishiki')
await expect(card(p, JAPAN)).toBeVisible({ timeout: 15_000 })
await expect(card(p, JAPAN).getByText('Nishiki Market')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => card(p, JAPAN),
act: async p => {
await p.getByRole('button', { name: 'Clear search' }).click()
await expect(card(p, LISBON)).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
if (await tripSearch(p).count()) await tripSearch(p).fill('')
},
},
'create-trip': {
guide: guide('create-trip'),
start: openDashboard,
steps: [
{
target: p => p.locator('.add-trip-card'),
act: async p => { await p.locator('.add-trip-card').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => titleField(p),
act: async p => { await typeInto(p, titleField(p), NEW_TRIP) },
},
{
target: p => datePicker(p, 'Start Date'),
act: async p => {
await pickDate(p, datePicker(p, 'Start Date'), 8, pickerLabelOn(monthsAhead(8, 8)))
await beat(p, 500)
// The end date follows the start date, so its calendar already opens on May.
await pickDate(p, datePicker(p, 'End Date'), 0, pickerLabelOn(monthsAhead(8, 15)))
},
},
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
},
{
target: p => modal(p).getByRole('button', { name: 'Create New Trip' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create New Trip' }).click()
await expect(card(p, NEW_TRIP)).toBeVisible({ timeout: 15_000 })
},
},
],
cleanup: async p => {
// The delete guide removes this trip on camera; until then it stays.
void p
},
},
'edit-trip': {
guide: guide('edit-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, NORWAY, 'Edit'),
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => titleField(p),
act: async p => {
await titleField(p).fill('')
await typeInto(p, titleField(p), 'Norway Road Trip')
},
},
{
target: p => modal(p).getByRole('button', { name: 'Update' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update' }).click()
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
},
},
],
},
'cover-image': {
guide: guide('cover-image'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, LISBON, 'Edit'),
act: async p => { await cardAction(p, LISBON, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
act: async p => {
await modal(p).locator('input[type="file"]').setInputFiles(await coverFixture())
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
},
},
{
prepare: async p => { await settle(p) },
target: p => modal(p).getByRole('button', { name: 'Update' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update' }).click()
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
await expect(card(p, LISBON).locator('.trip-cover img')).toBeVisible({ timeout: 15_000 })
},
},
],
},
'duplicate-trip': {
guide: guide('duplicate-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, LISBON, 'Duplicate'),
act: async p => { await cardAction(p, LISBON, 'Duplicate').click() },
},
{
target: p => dialog(p).getByRole('button', { name: 'Copy trip' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Copy trip' }).click()
await expect(p.locator('.trip-card').filter({ hasText: LISBON })).toHaveCount(2, { timeout: 15_000 })
},
},
],
cleanup: async p => {
// Drop the copy over the API so the grid is back to the seed for the next
// guide. By title, not by position: the list came back in an order that
// left the copy standing, and a trip nobody expected then broke a guide
// three files later, in a screen that has nothing to do with this one.
const res = await p.request.get('/api/trips')
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
const trips = Array.isArray(body) ? body : (body.trips ?? [])
for (const t of trips.filter(x => x.title !== LISBON && x.title.startsWith(LISBON))) {
const dropped = await p.request.delete(`/api/trips/${t.id}`)
if (!dropped.ok()) throw new Error(`could not drop the copy "${t.title}": ${dropped.status()}`)
}
},
},
'archive-trip': {
guide: guide('archive-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, NORWAY, 'Archive'),
act: async p => {
await cardAction(p, NORWAY, 'Archive').click()
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
},
},
{
target: p => p.locator('.seg').getByRole('button', { name: 'Archived' }),
act: async p => {
await p.locator('.seg').getByRole('button', { name: 'Archived' }).click()
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => cardAction(p, NORWAY, 'Restore'),
act: async p => {
await cardAction(p, NORWAY, 'Restore').click()
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
await beat(p, 400)
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
},
},
],
},
'delete-trip': {
guide: guide('delete-trip'),
start: async p => {
await openDashboard(p)
await expect(card(p, NEW_TRIP)).toBeVisible()
},
steps: [
{
target: p => cardAction(p, NEW_TRIP, 'Delete'),
act: async p => { await cardAction(p, NEW_TRIP, 'Delete').click() },
},
{
target: p => dialog(p).getByRole('button', { name: 'Delete', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(card(p, NEW_TRIP)).toBeHidden({ timeout: 15_000 })
},
},
],
},
'filter-and-view': {
guide: guide('filter-and-view'),
start: openDashboard,
steps: [
{
target: p => p.locator('.seg'),
act: async p => {
await p.locator('.seg').getByRole('button', { name: 'Completed' }).click()
await beat(p, 900)
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
await expect(card(p, LISBON)).toBeVisible()
},
},
{
target: p => p.getByRole('button', { name: 'Toggle view' }),
act: async p => {
await p.getByRole('button', { name: 'Toggle view' }).click()
await expect(p.locator('.trips.list-view')).toBeVisible()
},
},
],
cleanup: async p => { await p.getByRole('button', { name: 'Toggle view' }).click() },
},
'calendar-feed': {
guide: guide('calendar-feed'),
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Subscribe to all trips calendar' }),
act: async p => { await p.getByRole('button', { name: 'Subscribe to all trips calendar' }).click() },
},
{
target: p => p.getByRole('button', { name: 'Enable calendar subscription' }),
act: async p => {
await p.getByRole('button', { name: 'Enable calendar subscription' }).click()
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
},
},
{
// The subscription dialog sits in the shared dialog frame.
target: p => p.locator('.trek-modal-backdrop').last().locator('> [role="presentation"], > div').first(),
},
],
cleanup: async p => { await p.keyboard.press('Escape') },
},
widgets: {
guide: guide('widgets'),
start: openDashboard,
steps: [
{
prepare: async p => {
await p.locator('nav button').filter({ has: p.locator('svg.lucide-chevron-down') }).first().click()
await beat(p, 500)
},
target: p => p.getByRole('link', { name: 'Settings' }),
act: async p => {
await p.getByRole('link', { name: 'Settings' }).click()
await expect(p).toHaveURL(/\/settings/)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Appearance', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Appearance', exact: true }).first().click()
await expect(p.getByText('Dashboard widgets', { exact: true }).first()).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await p.getByText('Dashboard widgets', { exact: true }).first().scrollIntoViewIfNeeded()
await beat(p, 400)
},
// The Section card itself: the nearest rounded container around its heading.
target: p => p.getByText('Dashboard widgets', { exact: true }).first().locator('xpath=ancestor::section[1]'),
},
{
target: p => p.getByRole('link', { name: 'My Trips' }),
act: async p => {
await p.getByRole('link', { name: 'My Trips' }).click()
await expect(p.locator('.hero-trip')).toBeVisible()
await settle(p)
},
},
],
},
'currency-widget': {
guide: guide('currency-widget'),
start: openDashboard,
steps: [
{
target: p => p.locator('.tool').filter({ hasText: 'Currency' }).locator('.fx-input'),
act: async p => {
const amount = p.locator('.tool').filter({ hasText: 'Currency' }).locator('input.amt').first()
await amount.fill('')
await typeInto(p, amount, '250')
},
},
{
target: p => p.getByRole('button', { name: 'Swap currencies' }),
act: async p => { await p.getByRole('button', { name: 'Swap currencies' }).click() },
},
],
},
'timezones-widget': {
guide: guide('timezones-widget'),
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Add timezone' }),
act: async p => {
await p.getByRole('button', { name: 'Add timezone' }).click()
await beat(p, 600)
},
},
{
prepare: async p => {
// Close the picker again so the row's remove button is what the picture shows.
await p.getByRole('button', { name: 'Add timezone' }).click()
await p.locator('.tz-row').first().hover()
},
target: p => p.locator('.tz-row').first().locator('.tz-del'),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
})
test('every registered dashboard guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(dashboardContext.guides.slice().sort())
})
test('hero: the dashboard', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, dashboardContext.id, openDashboard)
})
for (const id of dashboardContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+357
View File
@@ -0,0 +1,357 @@
/**
* A synthetic Dawarich recording for the help-center pictures.
*
* TREK only ever reads from Dawarich, so the media run has to put a recording
* there itself. This module makes one: a phone track that follows the seeded
* demo trip through Tokyo and Kyoto, day by day, plus five short trips in the
* year before it so the Atlas has countries to offer. Everything is relative to
* the day the pictures are taken, so it stays inside the trip's dates whenever
* the run happens.
*
* The points go through Dawarich's own batch endpoint as GeoJSON features;
* see `ensureDawarichFixtures` in fixtures.ts for the upload.
*/
export interface TrackFeature {
type: 'Feature'
geometry: { type: 'Point'; coordinates: [number, number] }
properties: {
timestamp: string
horizontal_accuracy: number
altitude: number
speed: number
device_id: string
}
}
/** One place on a day: when the phone gets there (local time) and how long it stays. */
interface Waypoint {
t: string
lat: number
lng: number
stay?: number
}
interface DayPlan {
offset: number
utcOffset: number
points: Waypoint[]
}
export const DAWARICH_DEVICE = 'trek-help-fixture'
const JST = 9
// Every place the trail visits. The trip's own places come first and match the
// seed's coordinates, so the dashed line ends exactly on the markers.
const P = {
sensoji: [35.7148, 139.7967],
nakamise: [35.7118, 139.7963],
asakusaStation: [35.7109, 139.7975],
toyosuStation: [35.6551, 139.7961],
teamlab: [35.6486, 139.79],
shinjukuStation: [35.6896, 139.7006],
gracery: [35.6946, 139.7012],
shibuya: [35.6595, 139.7005],
harajuku: [35.6702, 139.7027],
meiji: [35.6764, 139.6993],
ueno: [35.7146, 139.7714],
akihabara: [35.6984, 139.7731],
ginza: [35.6717, 139.765],
tsukiji: [35.6654, 139.7707],
tokyoTower: [35.6586, 139.7454],
roppongi: [35.6605, 139.7292],
tokyoStation: [35.6812, 139.7671],
shinYokohama: [35.5075, 139.6174],
nagoya: [35.1709, 136.8815],
kyotoStation: [34.9858, 135.7588],
kanra: [34.9942, 135.7592],
fushimi: [34.9671, 135.7727],
arashiyama: [35.017, 135.6716],
togetsukyo: [35.013, 135.6776],
nishiki: [35.005, 135.7649],
kiyomizu: [34.9949, 135.785],
gion: [35.0037, 135.7752],
yasaka: [35.0037, 135.7785],
todaiji: [34.689, 135.8398],
naraPark: [34.6851, 135.843],
ginkakuji: [35.027, 135.7982],
philosophersPath: [35.0227, 135.7948],
nanzenji: [35.0114, 135.7939],
heian: [35.0163, 135.7824],
} as const
type Place = readonly [number, number]
const at = (t: string, [lat, lng]: Place, stay?: number): Waypoint => ({ t, lat, lng, stay })
/** The trip itself: the last day is the picture day (offset 0), the first is nine days before. */
function japanDays(): DayPlan[] {
const day = (offset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset: JST, points })
return [
day(-9, [
at('08:40', P.gracery, 10),
at('09:30', P.sensoji, 90),
at('11:05', P.nakamise, 40),
at('11:50', P.asakusaStation, 5),
at('12:40', P.ginza, 70),
at('13:50', P.toyosuStation, 5),
at('14:00', P.teamlab, 120),
at('16:10', P.toyosuStation, 5),
at('17:00', P.shinjukuStation, 10),
at('17:15', P.gracery, 60),
at('19:00', P.shinjukuStation, 90),
at('21:00', P.gracery, 30),
]),
day(-8, [
at('09:00', P.gracery, 10),
at('09:40', P.shibuya, 45),
at('10:40', P.harajuku, 30),
at('11:20', P.meiji, 75),
at('13:00', P.harajuku, 60),
at('14:30', P.shibuya, 90),
at('16:40', P.shinjukuStation, 10),
at('17:00', P.gracery, 240),
]),
day(-7, [
at('09:10', P.gracery, 10),
at('10:00', P.ueno, 120),
at('12:30', P.akihabara, 100),
at('14:40', P.ginza, 120),
at('17:20', P.shinjukuStation, 10),
at('17:40', P.gracery, 200),
]),
day(-6, [
at('08:30', P.gracery, 10),
at('09:20', P.tsukiji, 90),
at('11:30', P.tokyoTower, 80),
at('13:30', P.roppongi, 150),
at('16:40', P.shinjukuStation, 10),
at('17:00', P.gracery, 240),
]),
day(-5, [
at('07:40', P.gracery, 10),
at('08:15', P.tokyoStation, 15),
at('08:48', P.shinYokohama, 1),
at('10:05', P.nagoya, 1),
at('10:45', P.kyotoStation, 20),
at('11:20', P.kanra, 120),
at('14:00', P.gion, 90),
at('16:20', P.kanra, 30),
at('17:00', P.fushimi, 150),
at('20:00', P.kanra, 60),
]),
day(-4, [
at('08:30', P.kanra, 10),
at('09:30', P.togetsukyo, 30),
at('10:10', P.arashiyama, 60),
at('11:20', P.togetsukyo, 40),
at('12:30', P.nishiki, 90),
at('14:20', P.gion, 60),
at('15:40', P.kanra, 240),
]),
day(-3, [
at('09:00', P.kanra, 10),
at('09:50', P.kiyomizu, 100),
at('11:50', P.yasaka, 40),
at('12:40', P.gion, 120),
at('15:00', P.heian, 60),
at('16:30', P.kanra, 240),
]),
day(-2, [
at('08:20', P.kanra, 10),
at('08:45', P.kyotoStation, 15),
at('09:45', P.naraPark, 60),
at('11:00', P.todaiji, 90),
at('12:40', P.naraPark, 120),
at('15:30', P.kyotoStation, 10),
at('16:00', P.kanra, 200),
]),
day(-1, [
at('09:00', P.kanra, 10),
at('09:50', P.nanzenji, 60),
at('11:00', P.philosophersPath, 45),
at('11:50', P.ginkakuji, 60),
at('13:20', P.nishiki, 90),
at('15:20', P.kanra, 240),
]),
day(0, [
at('08:30', P.kanra, 30),
at('09:30', P.gion, 60),
at('11:00', P.nishiki, 60),
at('12:30', P.kanra, 60),
at('14:00', P.kyotoStation, 120),
]),
]
}
/**
* Five short trips in the year before the pictures, in countries the Atlas does
* not have yet: Portugal, Jordan, Czechia, South Korea and Taiwan. Jordan
* dwells at Petra long enough for the wishlist rule (250 m, 20 minutes).
*/
function worldDays(): DayPlan[] {
const day = (offset: number, utcOffset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset, points })
return [
// Portugal, spring
day(-158, 1, [
at('09:30', [38.7077, -9.1366], 60),
at('11:00', [38.7114, -9.13], 120),
at('14:00', [38.6979, -9.2065], 90),
at('15:50', [38.6916, -9.216], 60),
at('18:00', [38.7077, -9.1366], 120),
]),
day(-157, 1, [
at('10:00', [41.1407, -8.611], 90),
at('12:00', [41.147, -8.6149], 60),
at('13:30', [41.1403, -8.6094], 120),
at('16:00', [41.1496, -8.6109], 120),
]),
// Jordan, late winter
day(-205, 3, [
at('09:00', [31.9544, 35.9344], 90),
at('11:00', [31.9539, 35.9106], 120),
at('15:00', [31.717, 35.587], 150),
]),
day(-204, 3, [
at('08:30', [30.3285, 35.4444], 90),
at('10:20', [30.3222, 35.4517], 120),
at('13:00', [30.3285, 35.4444], 60),
at('15:30', [29.576, 35.42], 180),
]),
// Czechia, early summer
day(-100, 2, [
at('09:30', [50.0875, 14.4213], 90),
at('11:15', [50.0865, 14.4114], 45),
at('12:30', [50.0911, 14.4016], 150),
at('16:00', [50.0645, 14.4183], 90),
]),
day(-99, 2, [
at('10:00', [50.0793, 14.4275], 120),
at('13:00', [50.0865, 14.4114], 60),
at('15:00', [50.0875, 14.4213], 120),
]),
// South Korea and Taiwan, one journey last autumn
day(-300, 9, [
at('09:30', [37.5796, 126.977], 120),
at('12:00', [37.5826, 126.9831], 90),
at('14:30', [37.5636, 126.9856], 120),
at('17:30', [37.5512, 126.9882], 90),
]),
day(-299, 9, [
at('10:00', [37.5563, 126.9236], 150),
at('14:00', [37.5796, 126.977], 90),
]),
day(-297, 8, [
at('10:00', [25.0372, 121.4999], 60),
at('11:30', [25.0421, 121.5074], 120),
at('15:00', [25.0339, 121.5645], 120),
at('18:30', [25.088, 121.5245], 120),
]),
day(-296, 8, [
at('10:30', [25.1096, 121.8446], 240),
at('16:00', [25.0421, 121.5074], 120),
]),
]
}
/** Deterministic jitter, so two runs produce the same recording. */
function noise(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
return x - Math.floor(x)
}
function isoAt(dayIso: string, hhmm: string, utcOffset: number, plusSeconds = 0): string {
const [h, m] = hhmm.split(':').map(Number)
const local = Date.UTC(+dayIso.slice(0, 4), +dayIso.slice(5, 7) - 1, +dayIso.slice(8, 10), h, m)
return new Date(local - utcOffset * 3600_000 + plusSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
function minutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number)
return h * 60 + m
}
function shiftDay(baseDay: string, offset: number): string {
const d = new Date(`${baseDay}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + offset)
return d.toISOString().slice(0, 10)
}
function feature(lng: number, lat: number, timestamp: string, speed: number, seed: number): TrackFeature {
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [+lng.toFixed(6), +lat.toFixed(6)] },
properties: {
timestamp,
horizontal_accuracy: 4 + Math.round(noise(seed) * 8),
altitude: 20 + Math.round(noise(seed + 1) * 30),
speed: +speed.toFixed(2),
device_id: DAWARICH_DEVICE,
},
}
}
/** Turns one day's waypoints into a track: a point a minute while moving, one every five while staying. */
function dayFeatures(dayIso: string, plan: DayPlan, seedBase: number): TrackFeature[] {
const out: TrackFeature[] = []
const pts = plan.points
let seed = seedBase
for (let i = 0; i < pts.length; i++) {
const here = pts[i]
const stay = here.stay ?? 0
const arrive = minutes(here.t)
for (let k = 0; k <= stay; k += 5) {
seed++
const wobble = 0.00012
out.push(
feature(
here.lng + (noise(seed) - 0.5) * wobble,
here.lat + (noise(seed + 7) - 0.5) * wobble,
isoAt(dayIso, here.t, plan.utcOffset, k * 60),
0,
seed,
),
)
}
const next = pts[i + 1]
if (!next) break
const leaveAt = arrive + stay
const travel = minutes(next.t) - leaveAt
if (travel <= 0) continue
// Moving: straight line with a little sway, a point a minute. Long hops
// (the Shinkansen) get thinned out so a day never exceeds Dawarich's page.
const step = travel > 60 ? 2 : 1
const km = Math.hypot((next.lat - here.lat) * 111, (next.lng - here.lng) * 91)
const speed = (km * 1000) / (travel * 60)
for (let m = step; m < travel; m += step) {
seed++
const f = m / travel
const sway = Math.sin(f * Math.PI) * 0.0008 * (noise(seed) - 0.5)
out.push(
feature(
here.lng + (next.lng - here.lng) * f + sway,
here.lat + (next.lat - here.lat) * f + sway,
isoAt(dayIso, here.t, plan.utcOffset, (stay + m) * 60),
speed,
seed,
),
)
}
}
return out
}
/** The whole recording for a picture day: the trip's ten days plus the year before. */
export function dawarichRecording(baseDay: string): { trip: TrackFeature[]; world: TrackFeature[] } {
const build = (plans: DayPlan[], seedBase: number) =>
plans.flatMap((plan, i) => dayFeatures(shiftDay(baseDay, plan.offset), plan, seedBase + i * 10_000))
return { trip: build(japanDays(), 1), world: build(worldDays(), 500_000) }
}
/** The UTC window one part of the recording covers, for checking whether it is already there. */
export function recordingWindow(baseDay: string, part: 'trip' | 'world'): { from: string; to: string } {
const plans = part === 'trip' ? japanDays() : worldDays()
const offsets = plans.map(p => p.offset)
return { from: shiftDay(baseDay, Math.min(...offsets) - 1), to: shiftDay(baseDay, Math.max(...offsets) + 1) }
}
+364
View File
@@ -0,0 +1,364 @@
import type { APIRequestContext } from '@playwright/test'
import { day, PICTURE_DAY_ISO } from '../dates'
import { DAWARICH_DEVICE, dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track'
/**
* The services outside TREK that a few guides need: an AirTrail with flights,
* a Dawarich with a recording, a Nextcloud with a folder of documents. Each is
* reached through its own API with the address and key from the environment
* (`e2e/help/media.env`, see README), and each fixture puts the service into
* the state the guide expects before TREK is connected to it. Nothing here is
* silent: a missing variable or an unreachable service fails the run rather
* than producing a picture of an empty panel.
*
* TREK's SSRF guard refuses loopback whatever the flags say, so every address
* has to be the machine's LAN address, and the media backend needs
* ALLOW_INTERNAL_NETWORK=true to reach it at all.
*/
function env(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`)
return value.replace(/\/+$/, '')
}
async function expectOk(res: Response, what: string): Promise<Response> {
if (!res.ok) throw new Error(`${what}: HTTP ${res.status} ${await res.text()}`)
return res
}
// ── AirTrail ────────────────────────────────────────────────────────────────
interface AirtrailWanted {
flightNumber: string
from: string
to: string
offset: number
departureTime: string
arrivalTime: string
airline: string
aircraft: string
seat: 'window' | 'aisle'
seatNumber: string
}
/**
* Two flights home on the trip's last day that connect in Tokyo, so the picker
* offers to import them as one flight with a layover, and the flights of the
* Lisbon weekend in spring for its "Other flights" list. LH716 out is already
* in the seed and would only be skipped as a duplicate, so it stays out.
*/
export const AIRTRAIL_FLIGHTS: AirtrailWanted[] = [
{ flightNumber: 'NH16', from: 'ITM', to: 'HND', offset: 0, departureTime: '08:00', arrivalTime: '09:10', airline: 'ANA', aircraft: 'B789', seat: 'window', seatNumber: '12A' },
{ flightNumber: 'LH717', from: 'HND', to: 'FRA', offset: 0, departureTime: '12:35', arrivalTime: '18:25', airline: 'DLH', aircraft: 'A359', seat: 'aisle', seatNumber: '31C' },
{ flightNumber: 'TP573', from: 'FRA', to: 'LIS', offset: -158, departureTime: '10:20', arrivalTime: '12:25', airline: 'TAP', aircraft: 'A320', seat: 'window', seatNumber: '7F' },
{ flightNumber: 'TP574', from: 'LIS', to: 'FRA', offset: -156, departureTime: '13:10', arrivalTime: '17:05', airline: 'TAP', aircraft: 'A320', seat: 'aisle', seatNumber: '9C' },
]
interface AirtrailFlightRow {
id: number
flightNumber: string | null
date: string
passengers?: { id: number; userId: string | null }[]
seats?: { id: number; userId: string | null }[]
}
/** Puts the wanted flights into the AirTrail account the key belongs to, once. */
export async function ensureAirtrailFlights(): Promise<void> {
const base = env('HELP_MEDIA_AIRTRAIL_URL')
const headers = { Authorization: `Bearer ${env('HELP_MEDIA_AIRTRAIL_API_KEY')}`, 'Content-Type': 'application/json' }
const listed = await expectOk(await fetch(`${base}/api/flight/list`, { headers }), 'AirTrail flight list')
const { flights } = (await listed.json()) as { flights: AirtrailFlightRow[] }
for (const wanted of AIRTRAIL_FLIGHTS) {
const date = day(wanted.offset)
const existing = flights.find(f => f.flightNumber === wanted.flightNumber)
if (existing && existing.date === date) continue
const owner = (existing?.passengers ?? existing?.seats ?? []).find(p => p.userId)
const body = {
...(existing ? { id: existing.id } : {}),
from: wanted.from,
to: wanted.to,
departure: date,
departureTime: wanted.departureTime,
arrival: date,
arrivalTime: wanted.arrivalTime,
airline: wanted.airline,
flightNumber: wanted.flightNumber,
aircraft: wanted.aircraft,
passengers: [{ ...(owner ? { id: owner.id } : {}), userId: '<USER_ID>', seat: wanted.seat, seatNumber: wanted.seatNumber, seatClass: 'economy', flightReason: 'leisure' }],
}
await expectOk(await fetch(`${base}/api/flight/save`, { method: 'POST', headers, body: JSON.stringify(body) }), `AirTrail save ${wanted.flightNumber}`)
}
}
/** Switches the addon on and connects the signed-in user to the AirTrail, loudly. */
export async function ensureAirtrailConnection(api: APIRequestContext): Promise<void> {
await ensureAirtrailFlights()
await toggleAddon(api, 'airtrail', true)
const saved = await api.put('/api/integrations/airtrail/settings', {
data: { url: env('HELP_MEDIA_AIRTRAIL_URL'), apiKey: env('HELP_MEDIA_AIRTRAIL_API_KEY') },
})
if (!saved.ok()) throw new Error(`could not save the AirTrail connection: ${saved.status()} ${await saved.text()}`)
const status = await api.get('/api/integrations/airtrail/status')
const body = (await status.json()) as { connected: boolean; error?: string }
if (!body.connected) throw new Error(`AirTrail is not reachable from the media backend: ${body.error ?? status.status()} (is ALLOW_INTERNAL_NETWORK=true?)`)
}
export async function disconnectAirtrail(api: APIRequestContext): Promise<void> {
await api.put('/api/integrations/airtrail/settings', { data: { url: '' } })
await toggleAddon(api, 'airtrail', false)
}
// ── Dawarich ────────────────────────────────────────────────────────────────
/** The ids of the fixture's own points between two instants (ISO), every page of them. */
async function fixturePointIds(base: string, key: string, from: string, to: string): Promise<number[]> {
const ids: number[] = []
for (let page = 1; ; page++) {
const res = await expectOk(
await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1000&page=${page}`, { headers: { Authorization: `Bearer ${key}` } }),
`Dawarich points ${from}..${to}`,
)
const points = (await res.json()) as { id: number; tracker_id?: string | null }[]
// Only what this fixture uploaded: whatever else the account holds is not ours to touch.
ids.push(...points.filter(p => p.tracker_id === DAWARICH_DEVICE).map(p => p.id))
if (points.length === 0 || page >= Number(res.headers.get('x-total-pages') ?? 1)) return ids
}
}
const shiftIso = (iso: string, days: number) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
const aroundInstant = (iso: string, seconds: number) => new Date(Date.parse(iso) + seconds * 1000).toISOString()
/**
* Uploads the synthetic recording, one part at a time, unless this run's copy
* of it is already there.
*
* The recording moves with the picture day, so the copy an earlier run left
* sits a few days off this run's and overlaps its window. Asking whether the
* window held any point at all found that copy and uploaded nothing, and the
* trip map then drew the earlier run's last days, in Kyoto, on this run's
* first days, which are in Tokyo. So the probe asks for this run's first and
* last point, and a part that lacks either is cleared of the fixture's own
* points, a month either side of its window, before it goes up again.
*/
export async function ensureDawarichRecording(): Promise<void> {
const base = env('HELP_MEDIA_DAWARICH_URL')
const key = env('HELP_MEDIA_DAWARICH_API_KEY')
const recording = dawarichRecording(PICTURE_DAY_ISO)
for (const part of ['world', 'trip'] as const) {
const features = recording[part]
const ends = [features[0], features[features.length - 1]].map(f => f.properties.timestamp)
let present = true
for (const at of ends) {
if ((await fixturePointIds(base, key, aroundInstant(at, -1), aroundInstant(at, 1))).length === 0) present = false
}
if (present) continue
const { from, to } = recordingWindow(PICTURE_DAY_ISO, part)
const stale = await fixturePointIds(base, key, shiftIso(from, -30), shiftIso(to, 30))
for (let i = 0; i < stale.length; i += 1000) {
await expectOk(
await fetch(`${base}/api/v1/points/bulk_destroy`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ point_ids: stale.slice(i, i + 1000) }),
}),
`Dawarich clear ${part}`,
)
}
for (let i = 0; i < features.length; i += 500) {
const batch: TrackFeature[] = features.slice(i, i + 500)
await expectOk(
await fetch(`${base}/api/v1/points?api_key=${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: batch }),
}),
`Dawarich upload ${part} batch ${i / 500 + 1}`,
)
}
}
}
/** Two wishes the recording reaches, with coordinates so the scan can match them. */
export const DAWARICH_WISHES = [
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, country_code: 'JP' },
{ name: 'Wadi Rum', lat: 29.576, lng: 35.42, country_code: 'JO' },
]
/**
* Switches the addon on, connects the signed-in user to the Dawarich and adds
* the wishes. Automatic syncing stays off: a sync would seed suggestion rows
* that head the Places column of every later picture.
*/
export async function ensureDawarichConnection(api: APIRequestContext): Promise<void> {
await ensureDawarichRecording()
await toggleAddon(api, 'dawarich', true)
const saved = await api.put('/api/integrations/dawarich/settings', {
data: { url: env('HELP_MEDIA_DAWARICH_URL'), apiKey: env('HELP_MEDIA_DAWARICH_API_KEY'), allowInsecureTls: false, syncEnabled: false },
})
if (!saved.ok()) throw new Error(`could not save the Dawarich connection: ${saved.status()} ${await saved.text()}`)
// The probe wants the address in its body; the key it takes from the connection just saved.
const test = await api.post('/api/integrations/dawarich/test', { data: { url: env('HELP_MEDIA_DAWARICH_URL'), allowInsecureTls: false } })
const body = (await test.json()) as { connected: boolean; error?: string; errorDetail?: string }
if (!body.connected) throw new Error(`Dawarich is not reachable from the media backend: ${body.error ?? ''} ${body.errorDetail ?? ''} (is ALLOW_INTERNAL_NETWORK=true?)`)
const listRes = await api.get('/api/addons/atlas/bucket-list')
const { items } = (await listRes.json()) as { items: { name: string }[] }
for (const wish of DAWARICH_WISHES) {
if (items.some(i => i.name === wish.name)) continue
const created = await api.post('/api/addons/atlas/bucket-list', { data: wish })
if (!created.ok()) throw new Error(`could not add the ${wish.name} wish: ${created.status()} ${await created.text()}`)
}
}
export async function disconnectDawarich(api: APIRequestContext): Promise<void> {
await api.delete('/api/integrations/dawarich/settings')
await toggleAddon(api, 'dawarich', false)
}
// ── Nextcloud ───────────────────────────────────────────────────────────────
export const NEXTCLOUD_BASE_PATH = '/Reisen'
export const NEXTCLOUD_FOLDER = 'Autumn in Japan'
export const NEXTCLOUD_DOCUMENTS = ['ryokan-invoice.pdf', 'shinkansen-eticket.pdf']
/** What `drawPdf` in fixtures.ts takes; repeated here so this file needs nothing from it. */
export interface StoreDocument {
issuer: string
title: string
reference: string
fields: readonly (readonly [string, string])[]
footer: string
}
function davRoot(): { url: string; auth: string } {
const url = `${env('HELP_MEDIA_NEXTCLOUD_URL')}/remote.php/dav/files/${env('HELP_MEDIA_NEXTCLOUD_USER')}`
const auth = 'Basic ' + Buffer.from(`${env('HELP_MEDIA_NEXTCLOUD_USER')}:${env('HELP_MEDIA_NEXTCLOUD_PASSWORD')}`).toString('base64')
return { url, auth }
}
async function dav(method: string, path: string, body?: BodyInit, extra: Record<string, string> = {}): Promise<Response> {
const { url, auth } = davRoot()
const target = url + path.split('/').map(encodeURIComponent).join('/')
return fetch(target, { method, headers: { Authorization: auth, ...extra }, body })
}
/**
* The store side of the document-sync guide: a base folder with one trip
* folder holding two documents and nothing else, however the last run left it.
* `pdf` draws the documents, so this file stays free of binary fixtures.
*/
export async function ensureNextcloudFolder(pdf: (doc: StoreDocument) => Buffer): Promise<void> {
const folder = `${NEXTCLOUD_BASE_PATH}/${NEXTCLOUD_FOLDER}`
for (const dir of [NEXTCLOUD_BASE_PATH, folder]) {
const made = await dav('MKCOL', `${dir}/`)
if (![201, 405].includes(made.status)) throw new Error(`Nextcloud MKCOL ${dir}: HTTP ${made.status} ${await made.text()}`)
}
const listing = await dav('PROPFIND', `${folder}/`, undefined, { Depth: '1' })
if (listing.status !== 207) throw new Error(`Nextcloud PROPFIND ${folder}: HTTP ${listing.status}`)
const names = [...(await listing.text()).matchAll(/<d:href>([^<]+)<\/d:href>/g)]
.map(m => decodeURIComponent(m[1]).replace(/\/$/, '').split('/').pop() ?? '')
.filter(n => n && n !== NEXTCLOUD_FOLDER)
for (const name of names) {
if (NEXTCLOUD_DOCUMENTS.includes(name)) continue
const gone = await dav('DELETE', `${folder}/${name}`)
if (![204, 404].includes(gone.status)) throw new Error(`Nextcloud DELETE ${name}: HTTP ${gone.status}`)
}
const documents: Record<string, StoreDocument> = {
'ryokan-invoice.pdf': {
issuer: 'Hakone Ginyu',
title: 'Invoice',
reference: 'Invoice 2026-0918, confirmation RY-4471',
fields: [
['Guests', '2 adults, one room'],
['Nights', 'Two nights, half board'],
['Room and board', 'JPY 84,000'],
['Onsen tax', 'JPY 12,000'],
['Total, paid by card', 'JPY 96,000'],
],
footer: 'Paid in full. Thank you for staying with us.',
},
'shinkansen-eticket.pdf': {
issuer: 'JR Central',
title: 'Reserved seat ticket',
reference: 'Nozomi 21, Tokyo to Kyoto',
fields: [
['Date', 'Day 6 of the trip'],
['Departure / arrival', 'Tokyo 08:30, Kyoto 10:45'],
['Car and seats', 'Car 7, seats 12A and 12B'],
['Class', 'Ordinary, reserved, non-smoking'],
],
footer: 'Keep the ticket until you leave the station; the gate reads it twice.',
},
}
for (const name of NEXTCLOUD_DOCUMENTS) {
if (names.includes(name)) continue
const bytes = pdf(documents[name])
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const put = await dav('PUT', `${folder}/${name}`, body, { 'Content-Type': 'application/pdf' })
if (![201, 204].includes(put.status)) throw new Error(`Nextcloud PUT ${name}: HTTP ${put.status} ${await put.text()}`)
}
}
export const NEXTCLOUD_CONNECTION = () => ({
providerId: 'nextcloud',
baseUrl: env('HELP_MEDIA_NEXTCLOUD_URL'),
credentials: { login_name: env('HELP_MEDIA_NEXTCLOUD_USER'), app_password: env('HELP_MEDIA_NEXTCLOUD_PASSWORD'), base_path: NEXTCLOUD_BASE_PATH },
allowInsecureTls: false,
})
/** Takes every binding and connection off the trip, keeping the documents. */
export async function resetDocSync(api: APIRequestContext, tripId: number): Promise<void> {
const links = await api.get(`/api/trips/${tripId}/docsync/links`)
if (links.ok()) {
const body = (await links.json()) as { links?: { id: number }[] } | { id: number }[]
for (const link of Array.isArray(body) ? body : (body.links ?? [])) await api.delete(`/api/trips/${tripId}/docsync/links/${link.id}`)
}
const connections = await api.get(`/api/trips/${tripId}/docsync/connections`)
if (connections.ok()) {
const body = (await connections.json()) as { connections?: { id: number }[] } | { id: number }[]
for (const c of Array.isArray(body) ? body : (body.connections ?? [])) await api.delete(`/api/trips/${tripId}/docsync/connections/${c.id}`)
}
}
// ── Booking extractor ────────────────────────────────────────────────────────
/**
* Import from file is rendered only when the server can read a confirmation.
* The media run gets that from the extractor named in media.env
* (KITINERARY_EXTRACTOR_PATH, see README). The AI Parsing addon would show
* the button too, but with no model behind it every parse ends in a red card,
* so the guides ask for the extractor and nothing else.
*/
export async function requireExtractor(api: APIRequestContext): Promise<void> {
const res = await api.get('/api/health/features')
const body = (await res.json()) as { bookingImport?: boolean }
if (!body.bookingImport) {
throw new Error('the booking extractor is not available to the media backend: set KITINERARY_EXTRACTOR_PATH in e2e/help/media.env (see README)')
}
}
/**
* The review attaches the confirmation to the booking it made through the
* ordinary file upload, and that upload honours the administrator's Allowed
* File Types, which do not include a mail out of the box: the booking is
* saved and the .eml dropped without a word. So the list gets `eml` for the
* import guides and loses it again in their cleanup. Idempotent either way.
*/
export async function allowEmlUploads(api: APIRequestContext, allowed: boolean): Promise<void> {
const current = await api.get('/api/auth/app-settings')
const { allowed_file_types = '' } = (await current.json()) as { allowed_file_types?: string }
const types = allowed_file_types.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
if (types.includes('eml') === allowed) return
const next = allowed ? [...types, 'eml'] : types.filter(t => t !== 'eml')
const res = await api.put('/api/auth/app-settings', { data: { allowed_file_types: next.join(',') } })
if (!res.ok()) throw new Error(`could not ${allowed ? 'allow' : 'disallow'} .eml uploads: ${res.status()} ${await res.text()}`)
}
// ── shared ──────────────────────────────────────────────────────────────────
export async function toggleAddon(api: APIRequestContext, id: string, enabled: boolean): Promise<void> {
const res = await api.put(`/api/admin/addons/${id}`, { data: { enabled } })
if (!res.ok()) throw new Error(`could not switch addon ${id} ${enabled ? 'on' : 'off'}: ${res.status()} ${await res.text()}`)
}
File diff suppressed because it is too large Load Diff
+464
View File
@@ -0,0 +1,464 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { PICTURE_DAY_ISO } from '../dates'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import type { HelpGuide } from '../../src/help/types'
/**
* Runner for the help-center media (`npm run help:media`).
*
* A guide is defined once, in `src/help/contexts/<screen>.ts`; the file next to
* this one gives every step of it an action against the real app. From that
* single script this runner produces everything the panel shows:
*
* - one framed picture per step, with the element the reader is told to use
* ringed and numbered (`step-<n>.png`),
* - the state after the last step (`result.png`).
*
* And because every step has to succeed for the picture to exist, the same run
* is the test that the guide still matches the UI.
*
* Output lands in a staging directory; `promote.mjs` converts and moves it into
* `public/help-media/`, so a bad run never clobbers good artwork.
*/
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
/**
* A common desktop. Smaller frames squeeze the trip cards until their badges
* overlap the action buttons, which is not what a reader on a normal screen
* sees; the wiki shots use 1440×900 for the docs' narrower column.
*/
export const VIEWPORT = { width: 1920, height: 1080 }
/**
* The instant every picture is taken at: nine in the morning, UTC, on the
* picture day (`e2e/dates.ts`), which is the seeded trip's last day.
*
* A picture has to look the same whoever runs this and whenever, and the seed
* is relative to that day, so the browser's clock is pinned to it: were it
* left on the wall clock, a run late in the evening would see the trip's last
* stops in the past, and What's Next would have nothing left to list. Only
* the browser's `Date` is pinned, so timers keep running and nothing that
* waits stops waiting; the server keeps its own clock, which none of the
* pictures read.
*/
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
/** Put the page on the day the pictures are taken on. Call before navigating. */
async function pinClock(page: Page): Promise<void> {
await page.clock.setFixedTime(PICTURE_DAY)
}
export interface StepAction {
/** Bring the screen to where the step starts (runs after the previous step's `act`). */
prepare?: (page: Page) => Promise<void>
/** The element the reader is told to use; ringed and framed in the picture. */
target?: (page: Page) => Locator
/**
* Where to put the pointer for the picture when the target's centre is not on
* it (an SVG region whose bounding-box centre lies in a neighbour). Default:
* hover the target.
*/
hover?: (page: Page) => Promise<void>
/**
* A drag: the target is what the reader picks up, this is where it goes.
* Both are ringed and an arrow joins them, so one still shows the gesture.
*/
dropTo?: (page: Page) => Locator
/** What the step does. */
act?: (page: Page) => Promise<void>
}
export interface GuideScript {
guide: HelpGuide
/** Where the guide starts: navigate, dismiss notices, wait for data. */
start: (page: Page) => Promise<void>
/** Exactly one entry per registered step. */
steps: StepAction[]
/** Undo what the guide changed, so the next one starts from the seed again. */
cleanup?: (page: Page) => Promise<void>
}
/**
* 'still' takes the pictures at full speed. The mode stays as a switch so a
* recorder that plays the same scripts at a watchable pace can be added
* without touching them; nothing sets 'paced' today.
*/
let mode: 'still' | 'paced' = 'still'
/** A pause a person would make; nothing in still mode. */
export async function beat(page: Page, ms = 700): Promise<void> {
if (mode === 'paced') await page.waitForTimeout(ms)
}
/** Fill instantly for the pictures; type like a person when paced. */
export async function typeInto(page: Page, locator: Locator, text: string): Promise<void> {
if (mode === 'paced') {
await locator.click()
await locator.pressSequentially(text, { delay: 55 })
} else {
await locator.fill(text)
}
}
// ── Stills ────────────────────────────────────────────────────────────────────
/** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */
export async function captureGuide(page: Page, script: GuideScript): Promise<void> {
mode = 'still'
await pinClock(page)
const { guide } = script
if (script.steps.length !== guide.steps) {
throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`)
}
const dir = path.join(OUT_DIR, guide.id)
mkdirSync(dir, { recursive: true })
await script.start(page)
await settle(page)
for (let i = 0; i < script.steps.length; i++) {
const n = i + 1
const step = script.steps[i]
await step.prepare?.(page)
if (step.target && guide.media.steps) {
const target = step.target(page)
await expect(target, `guide "${guide.id}" step ${n}: target`).toBeVisible()
await target.scrollIntoViewIfNeeded()
let box = await target.boundingBox()
if (!box) throw new Error(`guide "${guide.id}" step ${n}: target has no box`)
if (box.height > VIEWPORT.height - 2 * PAD) {
// A block taller than the frame (a whole settings section): show its top
// rather than its middle, which is where the heading is.
await target.evaluate(el => el.scrollIntoView({ block: 'start' }))
await page.evaluate(() => {
const nav = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64
window.scrollBy(0, -nav - 24)
})
} else {
// Playwright scrolls only as far as it must, which leaves a target at the
// bottom edge and the frame full of whatever sits above it. Centre it so
// the picture shows the element in its surroundings. Works on whichever
// ancestor scrolls (the dashboard has its own pane, not the document).
await target.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
}
if (step.hover) await step.hover(page)
else await target.hover()
await settle(page)
box = (await target.boundingBox()) ?? box
if (step.dropTo) {
const dest = step.dropTo(page)
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
const to = await dest.boundingBox()
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
await ringDrag(page, box, to, n)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
} else {
const frame = frameFor(box)
await ring(page, box, n, frame)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frame })
}
await unring(page)
}
await step.act?.(page)
}
if (guide.media.result) {
await settle(page)
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) // nothing hovered in the after-picture
await page.waitForTimeout(250)
await page.screenshot({ path: path.join(dir, 'result.png') })
}
await script.cleanup?.(page)
}
/** The screen overview picture for a context. */
export async function captureHero(page: Page, contextId: string, start: (page: Page) => Promise<void>): Promise<void> {
mode = 'still'
await pinClock(page)
const dir = path.join(OUT_DIR, 'ctx')
mkdirSync(dir, { recursive: true })
await start(page)
await settle(page)
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4)
await page.waitForTimeout(250)
await page.screenshot({ path: path.join(dir, `${contextId}.png`) })
}
// ── Framing ──────────────────────────────────────────────────────────────────
const PAD = 64
const MIN_W = 960
const RATIO = 16 / 10
/**
* A 16:10 window around the target: at least MIN_W wide, big enough to hold the
* element with breathing room, kept inside the viewport. A target too large for
* the largest 16:10 frame that fits gets that frame, anchored at its top, so a
* long settings section shows its heading rather than its middle.
*/
export function frameFor(box: { x: number; y: number; width: number; height: number }) {
const { width: vw, height: vh } = VIEWPORT
const maxH = Math.min(vh, vw / RATIO)
const maxW = maxH * RATIO
let w = Math.max(MIN_W, box.width + 2 * PAD)
let h = Math.max(w / RATIO, box.height + 2 * PAD)
w = Math.max(w, h * RATIO)
const tall = h > maxH
if (w > maxW || tall) {
w = maxW
h = maxH
}
const x = clamp(box.x + box.width / 2 - w / 2, 0, vw - w)
const y = tall ? clamp(box.y - PAD / 2, 0, vh - h) : clamp(box.y + box.height / 2 - h / 2, 0, vh - h)
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) }
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
/**
* Ring the target, dim everything else, number it.
*
* The ring is the app's accent, which stands out on nearly every surface the
* guides walk over. Over the lightbox and the other near-black overlays it does
* not: a dark accent on a dark ground is a ring nobody can see. So the surface
* behind the ring is measured, and only when it has too little contrast against
* the accent does a white hairline go around the outside of the ring and the
* badge. Everywhere else the ring keeps the single line it always had.
*/
async function ring(
page: Page,
box: { x: number; y: number; width: number; height: number },
n: number,
frame: { x: number; y: number; width: number; height: number },
): Promise<void> {
await page.evaluate(
({ box, n, frame }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const pad = 6
const parse = (value: string): [number, number, number, number] | null => {
const text = value.trim()
if (text.startsWith('#')) {
const hex = text.length < 7 ? text.slice(1, 4).replace(/./g, c => c + c) : text.slice(1, 7)
const n = Number.parseInt(hex, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]
}
const inside = /rgba?\(([^)]+)\)/.exec(text)
if (!inside) return null
const parts = inside[1]
.split(/[\s,/]+/)
.filter(Boolean)
.map(Number)
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]
}
const luminance = (colour: [number, number, number, number]): number => {
const channel = (v: number) => {
const c = v / 255
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(colour[0]) + 0.7152 * channel(colour[1]) + 0.0722 * channel(colour[2])
}
// What lies just outside the ring, through any transparent layer.
const behind = (x: number, y: number): [number, number, number, number] => {
let node = document.elementFromPoint(
Math.max(1, Math.min(window.innerWidth - 2, x)),
Math.max(1, Math.min(window.innerHeight - 2, y)),
)
while (node) {
const colour = parse(getComputedStyle(node).backgroundColor)
if (colour && colour[3] > 0.2) return colour
node = node.parentElement
}
return [255, 255, 255, 1]
}
// The dim layer darkens that surface before the ring is drawn over it.
const ground = luminance(behind(box.x - pad - 4, box.y + box.height / 2)) * 0.7
const ink = luminance(parse(accent) || [17, 24, 39, 1])
const hairline = (Math.max(ground, ink) + 0.05) / (Math.min(ground, ink) + 0.05) < 3
const el = document.createElement('div')
el.id = 'trek-help-ring'
Object.assign(el.style, {
position: 'fixed',
left: `${box.x - pad}px`,
top: `${box.y - pad}px`,
width: `${box.width + 2 * pad}px`,
height: `${box.height + 2 * pad}px`,
borderRadius: '12px',
boxShadow: `0 0 0 3px ${accent}${hairline ? ', 0 0 0 5px rgba(255,255,255,0.92)' : ''}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
pointerEvents: 'none',
zIndex: '2147483647',
})
// Outside the corner where that is in shot, and otherwise inside, far
// enough in to clear the frame's edge: a row wider than the picture has
// no outside left of it, and the ring's own corner is off the picture too.
const ringLeft = box.x - pad
const ringTop = box.y - pad
const left = ringLeft - 14 >= frame.x + 2 ? -14 : Math.max(10, frame.x + 10 - ringLeft)
const top = ringTop - 14 >= frame.y + 2 ? -14 : Math.max(10, frame.y + 10 - ringTop)
const badge = document.createElement('div')
Object.assign(badge.style, {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
width: '28px',
height: '28px',
borderRadius: '999px',
background: accent,
color: accentText,
font: '700 13px/28px Poppins, system-ui, sans-serif',
textAlign: 'center',
boxShadow: hairline
? '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)'
: '0 2px 8px rgba(0,0,0,0.25)',
})
badge.textContent = String(n)
el.appendChild(badge)
document.body.appendChild(el)
},
{ box, n, frame },
)
}
type Box = { x: number; y: number; width: number; height: number }
/** The smallest box around two. */
function union(a: Box, b: Box): Box {
const x = Math.min(a.x, b.x)
const y = Math.min(a.y, b.y)
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }
}
/**
* The drag picture: a dim over everything but the two boxes, the dragged one
* ringed and numbered, the destination ringed in a dashed line, and an arrow
* from the one to the other, bowed a little so it reads as a movement.
*/
async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void> {
await page.evaluate(
({ from, to, n }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const pad = 6
const W = window.innerWidth
const H = window.innerHeight
const ns = 'http://www.w3.org/2000/svg'
const el = (tag: string, attrs: Record<string, string | number>) => {
const node = document.createElementNS(ns, tag)
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v))
return node
}
const svg = el('svg', { id: 'trek-help-ring', width: W, height: H, viewBox: `0 0 ${W} ${H}` })
Object.assign(svg.style, { position: 'fixed', left: '0', top: '0', pointerEvents: 'none', zIndex: '2147483647' })
const grow = (b: typeof from) => ({ x: b.x - pad, y: b.y - pad, w: b.width + 2 * pad, h: b.height + 2 * pad })
const a = grow(from)
const b = grow(to)
const defs = el('defs', {})
const mask = el('mask', { id: 'trek-help-mask' })
mask.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'white' }))
mask.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'black' }))
mask.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'black' }))
defs.appendChild(mask)
const marker = el('marker', { id: 'trek-help-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' })
marker.appendChild(el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: accent }))
defs.appendChild(marker)
svg.appendChild(defs)
svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'rgba(0,0,0,0.30)', mask: 'url(#trek-help-mask)' }))
svg.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3 }))
svg.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3, 'stroke-dasharray': '10 7' }))
// From the edge of the dragged box that faces the destination to the facing edge there.
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 }
const horizontal = Math.abs(bc.x - ac.x) > Math.abs(bc.y - ac.y)
const start = horizontal ? { x: bc.x > ac.x ? a.x + a.w : a.x, y: ac.y } : { x: ac.x, y: bc.y > ac.y ? a.y + a.h : a.y }
const end = horizontal ? { x: bc.x > ac.x ? b.x : b.x + b.w, y: bc.y } : { x: bc.x, y: bc.y > ac.y ? b.y : b.y + b.h }
const dx = end.x - start.x
const dy = end.y - start.y
const len = Math.hypot(dx, dy) || 1
const bow = Math.min(90, len * 0.18)
const ctrl = { x: (start.x + end.x) / 2 - (dy / len) * bow, y: (start.y + end.y) / 2 + (dx / len) * bow }
svg.appendChild(el('path', {
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
fill: 'none', stroke: 'rgba(255,255,255,0.9)', 'stroke-width': 8, 'stroke-linecap': 'round',
}))
svg.appendChild(el('path', {
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
fill: 'none', stroke: accent, 'stroke-width': 4, 'stroke-linecap': 'round', 'marker-end': 'url(#trek-help-arrow)',
}))
const badge = el('g', {})
badge.appendChild(el('circle', { cx: a.x, cy: a.y, r: 14, fill: accent }))
const text = el('text', { x: a.x, y: a.y + 5, 'text-anchor': 'middle', fill: accentText, 'font-family': 'Poppins, system-ui, sans-serif', 'font-size': 13, 'font-weight': 700 })
text.textContent = String(n)
badge.appendChild(text)
svg.appendChild(badge)
document.body.appendChild(svg)
},
{ from, to, n },
)
}
async function unring(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
}
/**
* The release-notes modal greets every user once per version and its overlay
* swallows every click underneath. clearNotices handles the plain notices; this
* one has its own close button.
*/
export async function dismissReleaseNotice(page: Page): Promise<void> {
const close = page.locator('.rn-close')
await close.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {})
for (let i = 0; i < 3 && (await close.isVisible().catch(() => false)); i++) {
await close.click()
await page.waitForTimeout(500)
}
await expect(page.locator('.rn-overlay')).toHaveCount(0)
}
/** Fonts loaded, images decoded, transitions landed, network quiet (bounded: /ws never idles). */
export async function settle(page: Page): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
await page.evaluate(async () => { await document.fonts.ready })
// Bounded: a picture whose request never answers (a tile, a CDN flag) must
// not hold the run; three seconds is longer than anything that does load.
await page.evaluate(async () => {
const pending = Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res }))
await Promise.race([Promise.all(pending), new Promise(res => setTimeout(res, 3000))])
})
await showLoadedTiles(page)
await page.waitForTimeout(450)
}
/**
* Show the raster tiles that have arrived.
*
* Leaflet fades a tile in over 200 ms and works out how far it has got from
* `+new Date()`. The clock is pinned to the picture day (see PICTURE_DAY), so
* that difference never grows, the tile keeps the opacity 0 it was created
* with, and the map is a grey rectangle in the picture even though every tile
* came back with a 200. Nothing else on the page fades from the wall clock, so
* this only ever touches tiles that are already loaded.
*/
async function showLoadedTiles(page: Page): Promise<void> {
await page.evaluate(() => {
// A rule, not an inline style: Leaflet re-runs the fade on every frame and
// writes the opacity back, so the only thing that holds is a stylesheet
// that outranks it. `leaflet-tile-loaded` is Leaflet's own class, put on a
// tile the moment its image arrives, so a tile still in flight stays hidden.
if (!document.getElementById('trek-help-tiles')) {
const style = document.createElement('style')
style.id = 'trek-help-tiles'
style.textContent = '.leaflet-tile-loaded { opacity: 1 !important; }'
document.head.appendChild(style)
}
for (const tile of document.querySelectorAll('img.leaflet-tile')) {
const img = tile as HTMLImageElement
if (img.complete && img.naturalWidth > 0) img.classList.add('leaflet-tile-loaded')
}
})
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
# Copy to e2e/help/media.env (not committed). Only the guides that show
# another service need these; every other guide runs without the file.
#
# The addresses must be the machine's LAN address: TREK's SSRF guard refuses
# loopback whatever the flags say, and the two lines at the bottom let the
# media backend reach a private network and find the booking extractor.
# AirTrail (Transports: Import from AirTrail). Key from Settings > Security.
HELP_MEDIA_AIRTRAIL_URL=http://192.168.1.10:3020
HELP_MEDIA_AIRTRAIL_API_KEY=
# Dawarich (Map: recorded route; Atlas: countries and wishes). Key from Account > API key.
HELP_MEDIA_DAWARICH_URL=http://192.168.1.10:3000
HELP_MEDIA_DAWARICH_API_KEY=
# Nextcloud (Files: Document sync). An app password, not the account password.
HELP_MEDIA_NEXTCLOUD_URL=http://192.168.1.10:8803
HELP_MEDIA_NEXTCLOUD_USER=admin
HELP_MEDIA_NEXTCLOUD_PASSWORD=
# For the media backend (client/e2e/server-launch.mjs passes the environment on).
ALLOW_INTERNAL_NETWORK=true
# The booking extractor. On Linux the package's binary; on Windows a small
# program that runs it in the TREK Docker image (see README).
KITINERARY_EXTRACTOR_PATH=/usr/lib/x86_64-linux-gnu/libexec/kf6/kitinerary-extractor
# Optional: the day the seed and the pictures are relative to (YYYY-MM-DD).
# Unset, every run takes today. Pin it while a picture set is made over
# several runs, so every run shows the same calendar.
# E2E_PICTURE_DAY=
+61
View File
@@ -0,0 +1,61 @@
// Moves the help-center media from the staging directory into public/help-media/,
// re-encoding the PNG captures to WebP on the way.
//
// Captures are 2x (3840px wide for a full frame). The panel shows a step
// picture at ~600px and a full frame at ~1000px, on HiDPI displays, so 1440px
// and 1920px keep them sharp at a fraction of the bytes. WebP rather than PNG
// on purpose: the service worker precaches `png`, and help pictures must not
// ride along in every visitor's first download.
//
// Usage: node e2e/help/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
const DEST = path.join(process.cwd(), 'public', 'help-media')
const STEP_WIDTH = 1440
const FRAME_WIDTH = 1920
const dry = process.argv.includes('--dry')
if (!existsSync(SRC)) {
console.error(`No media in ${SRC} — run \`npm run help:media\` first.`)
process.exit(1)
}
let before = 0
let after = 0
let files = 0
async function picture(src, dest, width) {
const bytes = statSync(src).size
before += bytes
if (dry) {
console.log(` ${path.relative(SRC, src)} → ${path.relative(DEST, dest)} (${(bytes / 1024).toFixed(0)} KB)`)
return
}
const img = sharp(src)
const meta = await img.metadata()
const pipeline = (meta.width ?? 0) > width ? img.resize({ width }) : img
await pipeline.webp({ quality: 84, effort: 5 }).toFile(dest)
after += statSync(dest).size
files++
}
mkdirSync(DEST, { recursive: true })
for (const dir of readdirSync(SRC)) {
const from = path.join(SRC, dir)
if (!statSync(from).isDirectory() || dir.startsWith('.')) continue
const to = path.join(DEST, dir)
mkdirSync(to, { recursive: true })
for (const file of readdirSync(from)) {
if (path.extname(file) !== '.png') continue
const width = file.startsWith('step-') ? STEP_WIDTH : FRAME_WIDTH
await picture(path.join(from, file), path.join(to, file.replace(/\.png$/, '.webp')), width)
}
}
const mb = (n) => (n / 1024 / 1024).toFixed(1)
console.log(dry ? `dry run: ${mb(before)} MB in staging` : `promoted ${files} files: ${mb(before)} MB → ${mb(after)} MB in ${path.relative(process.cwd(), DEST)}`)
+58
View File
@@ -0,0 +1,58 @@
// `npm run help:media`: the help-center media run on its own port pair, so it
// can record while `npm run dev` keeps serving 5173/3001 to whoever is
// watching. The ports travel as environment variables because Playwright's
// workers re-read the config, and only the environment reaches all of them.
//
// Extra arguments go straight to Playwright, e.g.
// npm run help:media -- --grep "pictures"
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
// The guides that show another service (AirTrail, Dawarich, a document store,
// the booking extractor) take its address and key from e2e/help/media.env,
// KEY=VALUE per line, which is not committed. media.env.example lists them.
// The file is read here so the backend inherits it too (server-launch.mjs
// spreads this environment): ALLOW_INTERNAL_NETWORK and the extractor path
// live in the same place.
function mediaEnv() {
const file = path.join(process.cwd(), 'e2e', 'help', 'media.env')
if (!existsSync(file)) return {}
const out = {}
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq < 1) continue
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"(.*)"$/, '$1')
}
return out
}
// The day the pictures are taken on, fixed once for the whole run so the seed
// project and every worker agree even across midnight (see e2e/dates.ts).
function today() {
const d = new Date()
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const fromFile = mediaEnv()
const env = {
...fromFile,
...process.env,
E2E_WEB_PORT: process.env.E2E_WEB_PORT || '5183',
E2E_API_PORT: process.env.E2E_API_PORT || '3011',
// The shell wins, then media.env (a day pinned there keeps a picture set
// consistent across several runs), then today.
E2E_PICTURE_DAY: process.env.E2E_PICTURE_DAY || fromFile.E2E_PICTURE_DAY || today(),
}
// The CLI's own entry, run by this node: no shell in between, so a --grep
// pattern with a `|` reaches Playwright untouched.
const cli = createRequire(import.meta.url).resolve('@playwright/test/cli')
const res = spawnSync(process.execPath, [cli, 'test', '--project=help-media', ...process.argv.slice(2)], {
stdio: 'inherit',
env,
})
process.exit(res.status ?? 1)
+344
View File
@@ -0,0 +1,344 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { createHmac } from 'node:crypto'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { settingsContext, settingsTabContexts, settingsGuides } from '../../src/help/contexts/settings'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the settings screens, keyed by the ids in
* `src/help/contexts/settings.ts`. One route, one tab per screen; most guides
* only frame a section, and the few that change something (theme, an API key,
* two-factor authentication) leave the account in a state the run can live
* with, because settings is the last file of the run.
*/
const TAB_OF: Record<string, string> = {
'settings-display': 'General',
'settings-appearance': 'Appearance',
'settings-map': 'Map',
'settings-notifications': 'Notifications',
'settings-integrations': 'Integrations',
'settings-offline': 'Offline',
'settings-account': 'Account',
}
const guide = (id: string): HelpGuide => {
const g = settingsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'General' }) }).first()
/** A settings card by the heading of its section. */
const section = (page: Page, title: string) =>
page.locator('h2').filter({ hasText: title }).first().locator("xpath=ancestor::section[1]")
/** A field of a section by its label. */
const field = (page: Page, sectionTitle: string, label: string) =>
section(page, sectionTitle).getByText(label, { exact: true }).first().locator('xpath=..')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A sub-section headed by an h3 inside a card (the passkeys block under Account). */
const subsection = (page: Page, title: string) => page.locator('h2, h3').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
async function openSettings(page: Page, tab: string): Promise<void> {
await page.goto('/settings')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
await settle(page)
}
const opener = (contextId: string) => (page: Page) => openSettings(page, TAB_OF[contextId])
/** RFC 6238 code for the secret the setup shows, so the run can enable 2FA for real. */
function totp(secretBase32: string): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const clean = secretBase32.toUpperCase().replace(/[^A-Z2-7]/g, '')
let bits = ''
for (const ch of clean) bits += alphabet.indexOf(ch).toString(2).padStart(5, '0')
const bytes = Buffer.from((bits.match(/.{8}/g) ?? []).map(b => parseInt(b, 2)))
const counter = Buffer.alloc(8)
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)))
const mac = createHmac('sha1', bytes).update(counter).digest()
const offset = mac[mac.length - 1] & 0x0f
const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]
return String(code % 1_000_000).padStart(6, '0')
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
// ── General ──────────────────────────────────────────────────────────────
'language-region': {
guide: guide('language-region'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Language & region', 'Language')),
only(p => section(p, 'Language & region')),
],
},
'travel-map-prefs': {
guide: guide('travel-map-prefs'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Travel & map', 'Always show booking routes').locator('xpath=..')),
only(p => field(p, 'Travel & map', 'Blur Booking Codes').locator('xpath=..')),
],
},
startup: {
guide: guide('startup'),
start: opener('settings-display'),
steps: [
{
target: p => field(p, 'Startup', 'Start page').locator('xpath=..'),
// The start tab only exists once the app opens on the active trip.
act: async p => { await section(p, 'Startup').getByRole('button', { name: 'Active trip' }).click(); await settle(p) },
},
only(p => field(p, 'Startup', 'Start tab').locator('xpath=..')),
],
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
},
// ── Appearance ───────────────────────────────────────────────────────────
'theme-scheme': {
guide: guide('theme-scheme'),
start: opener('settings-appearance'),
steps: [
{
target: p => section(p, 'Theme').getByRole('button', { name: 'Dark', exact: true }).locator('xpath=..'),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Light', exact: true }).click(); await beat(p, 400) },
},
{
target: p => section(p, 'Theme').getByText('Color scheme', { exact: true }).locator('xpath=..'),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click(); await settle(p) },
},
{
prepare: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
target: p => section(p, 'Theme').getByText('Custom accent', { exact: true }).locator('xpath=..'),
act: async p => {
await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click()
await settle(p)
},
},
],
cleanup: async p => {
await section(p, 'Theme').getByRole('button', { name: 'Default', exact: true }).click()
await settle(p)
},
},
readability: {
guide: guide('readability'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Readability').getByText('Transparency', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Readability').getByText('Text size', { exact: true }).first().locator('xpath=../..')),
],
},
'dashboard-widgets': {
guide: guide('dashboard-widgets'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Dashboard widgets')),
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
],
},
// ── Map ──────────────────────────────────────────────────────────────────
'map-provider': {
guide: guide('map-provider'),
start: opener('settings-map'),
steps: [
only(p => section(p, 'Map').getByText('Map Provider', { exact: true }).locator('xpath=..')),
only(p => section(p, 'Map').getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
only(p => section(p, 'Map').getByRole('button', { name: 'Save Map' }).locator('xpath=..')),
],
},
// ── Notifications ────────────────────────────────────────────────────────
'notification-channels': {
guide: guide('notification-channels'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Ntfy')),
only(p => section(p, 'Webhook URL')),
only(p => page_rows(p)),
],
},
// ── Integrations ─────────────────────────────────────────────────────────
'photo-providers': {
guide: guide('photo-providers'),
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'Immich')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=..')),
],
},
'api-keys': {
guide: guide('api-keys'),
start: opener('settings-integrations'),
steps: [
{
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
act: async p => {
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
await expect(modal(p)).toBeVisible()
await typeInto(p, modal(p).getByPlaceholder('e.g. Dawarich'), 'Home Assistant')
await modal(p).getByRole('button', { name: 'Create', exact: true }).click()
await expect(modal(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Done' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'mcp-oauth': {
guide: guide('mcp-oauth'),
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'MCP Configuration').getByText('MCP Endpoint', { exact: true }).locator('xpath=..')),
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'OAuth 2.1 Clients' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).locator('xpath=../..'),
},
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'API Tokens' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'Create New Token' }).locator('xpath=../..'),
},
],
},
// ── Offline ──────────────────────────────────────────────────────────────
'offline-prepare': {
guide: guide('offline-prepare'),
start: opener('settings-offline'),
steps: [
only(p => section(p, 'What to store offline')),
only(p => p.getByRole('button', { name: 'Download for offline use' })),
only(p => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
],
},
'offline-conflicts': {
guide: guide('offline-conflicts'),
start: opener('settings-offline'),
steps: [
// The conflicts section only exists while a conflict is open; the cache card shows their count.
only(p => section(p, 'Offline cache')),
only(p => p.getByRole('button', { name: 'Re-sync now' })),
],
},
// ── Account ──────────────────────────────────────────────────────────────
profile: {
guide: guide('profile'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
],
},
password: {
guide: guide('password'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
only(p => section(p, 'Change Password').getByRole('button', { name: 'Update password' })),
],
},
mfa: {
guide: guide('mfa'),
start: opener('settings-account'),
steps: [
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }),
act: async p => {
await subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }).click()
await expect(subsection(p, 'Two-factor authentication (2FA)').locator('code')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByPlaceholder('6-digit code').locator('xpath=../..'),
act: async p => {
const mfa = subsection(p, 'Two-factor authentication (2FA)')
const secret = (await mfa.locator('code').first().innerText()).trim()
await typeInto(p, mfa.getByPlaceholder('6-digit code'), totp(secret))
await mfa.getByRole('button', { name: 'Enable 2FA' }).click()
await expect(mfa.getByText('Backup codes').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
},
],
},
passkeys: {
guide: guide('passkeys'),
start: opener('settings-account'),
steps: [
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
{},
{},
],
},
'delete-account': {
guide: guide('delete-account'),
start: opener('settings-account'),
steps: [
only(p => p.getByRole('button', { name: 'Delete account' }).locator('xpath=..')),
],
},
}
/** The per-event preference rows under the channels. */
function page_rows(p: Page): Locator {
// The per-event matrix is the whole Notifications card in the redesigned tab.
return section(p, 'Notifications')
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
const ALL_CONTEXTS = [settingsContext, ...settingsTabContexts]
// The photo-provider sections exist only for providers the admin switched on, and the
// ntfy and webhook forms only for channels the admin turned on.
test.beforeAll(async ({ request }) => {
const res = await request.put('/api/admin/addons/immich', { data: { enabled: true } })
if (!res.ok()) throw new Error(`could not enable the Immich provider: ${res.status()} ${await res.text()}`)
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook' } })
if (!channels.ok()) throw new Error(`could not enable the notification channels: ${channels.status()} ${await channels.text()}`)
})
test('every registered settings guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
})
test('hero: settings', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, settingsContext.id, p => openSettings(p, 'General'))
})
for (const ctx of settingsTabContexts) {
test(`hero: ${ctx.id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, ctx.id, opener(ctx.id))
})
}
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+770
View File
@@ -0,0 +1,770 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { dotted } from '../dates'
import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures'
import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
import { requireExtractor, allowEmlUploads } from './external'
import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Bookings tab of a trip, keyed by the ids in
* `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan"
* with `ensureBookingsFixtures` in front of them, because the seed's only
* reservation is a flight and a flight is a transport: without the fixture this
* tab is its empty state. What a guide creates or changes it puts back in
* `cleanup`, mostly by deleting the booking and letting the fixture build it
* again, so the next one starts from the same seven cards.
*/
const guide = (id: string): HelpGuide => {
const g = tripBookingsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The tab id in the address is the legacy German one; its label is Bookings. */
const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' })
/** A booking's card in the Cards view: an article named after the booking. */
const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
/** The pencil and the bin in a card's head band, named by their tooltips. */
const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') =>
bookingCard(page, title).getByRole('button', { name, exact: true })
/** Open a booking's editor from its card, the way the texts tell the reader to. */
async function editFromCard(page: Page, title: string): Promise<void> {
await cardAction(page, title, 'Edit').click()
await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
await settle(page)
}
// ── The bar ───────────────────────────────────────────────────────────────────
/** The tab's one bar: its heading, then search, filter, the views and the add buttons. */
const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..')
const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true })
/** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */
const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ })
const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true })
/** The filter panel under the funnel: Status, Type and Travelers. */
const filterMenu = (page: Page) => page.getByRole('menu')
const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true })
const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true })
// ── Timeline and detail ───────────────────────────────────────────────────────
/** Trip | Day above the chart. */
const zoom = (page: Page) => page.getByRole('group', { name: 'Zoom', exact: true })
/** A day's heading on the Trip zoom; a click opens that day by the hour. */
const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) })
/** A bar of the timeline, named after its booking. */
const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true })
/** The booking's detail popup, labelled by its title. */
const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The booking the views guide opens: a tour on day 5, with a place and travellers. */
const VIEWED = { title: 'Fushimi Inari night walk', day: 5 }
// ── The editor ────────────────────────────────────────────────────────────────
/** A field of the booking form, by its own label; the block around it is the label's parent. */
const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first()
const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..')
/** The two-column or three-column row a field shares with its neighbours. */
const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..')
/** A labelled block of a dialog's body whose label is not a `<label>` (Who paid?). */
const section = (page: Page, name: string) =>
modal(page).locator('section').filter({ has: page.getByText(name, { exact: true }) }).first()
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
/** The title is typed into the head band; the field keeps the old placeholder. */
const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')
const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345')
const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00')
const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true })
/** The card's delete question is its own portal, with no backdrop class to find it by. */
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
/** The Booking Type is a pill in the head band; its name reads the type after the label. */
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
/** The pill's list is its own portal, named after the pill's label. */
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
/** The status pill beside it says what a click turns it into. */
const statusPill = (page: Page) => modal(page).getByRole('button', { name: /^Set to / })
async function openTypeMenu(page: Page): Promise<void> {
await typePill(page).click()
await expect(typeMenu(page)).toBeVisible()
await beat(page, 300)
}
async function pickType(page: Page, type: RegExp): Promise<void> {
await typeMenu(page).getByRole('button', { name: type }).first().click()
await expect(typeMenu(page)).toHaveCount(0)
}
async function openManualBooking(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Manual Booking' }).click()
await expect(modal(page).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
await settle(page)
}
/** The Travelers field opens its members as a list under it, inside the form. */
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
/** Open the CustomSelect that belongs to a label and take one option out of it. */
async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise<void> {
await block(page, field).getByRole('button').first().click()
await expect(selectMenu(page)).toBeVisible()
if (search) await selectMenu(page).locator('input').fill(search)
await beat(page, 300)
await selectMenu(page).getByRole('button', { name: option }).first().click()
await expect(selectMenu(page)).toHaveCount(0)
}
/**
* Set a date without touching the calendar: every date field has a keyboard
* button beside it that swaps in a DD.MM.YYYY box, which is one fill instead of
* a month of grid cells that move with the trip's dates.
*/
async function setDate(page: Page, nth: number, value: string): Promise<void> {
await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click()
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
await box.fill(value)
await box.press('Enter')
await expect(box).toHaveCount(0)
}
interface Booking {
id: number
title: string
}
async function bookingsOf(page: Page): Promise<Booking[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: Booking[] } | Booking[]
return Array.isArray(body) ? body : (body.reservations ?? [])
}
/**
* Delete bookings by title. The route takes the stay of an accommodation and
* the expense linked to a booking with it, so this undoes those too.
*/
async function deleteBookings(page: Page, ...titles: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const booking of (await bookingsOf(page)).filter(b => titles.includes(b.title))) {
await page.request.delete(`/api/trips/${tripId}/reservations/${booking.id}`)
}
}
/** Put a changed booking back: drop it, then let the fixture build it again. */
async function restore(page: Page, ...titles: string[]): Promise<void> {
await deleteBookings(page, ...titles)
await ensureBookingsFixtures(page.request)
}
/**
* What the tab remembers: the filters per trip for the browser tab, the view,
* its grouping, sorting and timeline zoom, and the folded sections in the
* browser. Every test has a context of its own, so this is only tidiness for a
* guide that runs another after it in the same page.
*/
async function clearPanelState(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.evaluate(id => {
try {
sessionStorage.removeItem(`trek-bookings-filters-bookings-${id}`)
for (const name of ['view', 'group', 'sort', 'timeline', 'transitApart']) localStorage.removeItem(`trek:bookings-bookings-${name}`)
localStorage.removeItem(`trek:bookings-bookings-collapsed:${id}`)
localStorage.removeItem('trek.bg-import-tasks')
} catch {
/* a browser that refuses storage has nothing to clear */
}
}, tripId)
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'booking-views': {
guide: guide('booking-views'),
start: openBookings,
steps: [
{
// The three icons are the subject; the pointer rests on List so its name shows.
target: viewSwitch,
hover: async p => { await viewButton(p, 'List').hover() },
act: async p => {
await viewButton(p, 'List').click()
await expect(viewButton(p, 'List')).toHaveAttribute('aria-pressed', 'true')
await expect(p.getByRole('button', { name: VIEWED.title, exact: true })).toBeVisible()
await settle(p)
},
},
{
target: p => viewButton(p, 'Timeline'),
act: async p => {
await viewButton(p, 'Timeline').click()
await expect(zoom(p)).toBeVisible()
await expect(zoom(p).getByRole('button', { name: 'Trip', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
target: p => dayHeading(p, VIEWED.day),
act: async p => {
await dayHeading(p, VIEWED.day).click()
await expect(zoom(p).getByRole('button', { name: 'Day', exact: true })).toHaveAttribute('aria-pressed', 'true')
await expect(timelineBar(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
only(zoom),
{
// Hovered for the picture: the bar's card of facts stands beside it.
target: p => timelineBar(p, VIEWED.title),
act: async p => {
await timelineBar(p, VIEWED.title).click()
await expect(detail(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
only(p => detail(p, VIEWED.title)),
],
cleanup: async p => {
await closeModal(p)
await clearPanelState(p)
},
},
'create-booking': {
guide: guide('create-booking'),
start: openBookings,
steps: [
{
target: p => p.getByRole('button', { name: 'Manual Booking' }),
act: openManualBooking,
},
{
prepare: async p => {
await openTypeMenu(p)
},
target: typeMenu,
act: async p => {
await pickType(p, /^Event$/)
await expect(typePill(p)).toHaveAccessibleName(/^Booking Type:\s*Event$/)
await settle(p)
},
},
{
prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') },
target: titleBox,
act: async p => {
await expect(saveButton(p, 'Add')).toBeEnabled()
await settle(p)
},
},
{
prepare: async p => {
await setDate(p, 0, dotted(-2))
await timeBox(p).nth(0).fill('16:30')
await setDate(p, 1, dotted(-2))
await timeBox(p).nth(1).fill('20:00')
await settle(p)
},
target: p => row(p, /^Date$/),
act: async p => {
await expect(saveButton(p, 'Add')).toBeEnabled()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, codeBox(p), 'MZ-5521')
await statusPill(p).click()
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
},
target: p => row(p, /^Booking Code$/),
act: settle,
},
{
target: p => saveButton(p, 'Add'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'Kabuki at the Minamiza')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteBookings(p, 'Kabuki at the Minamiza'),
},
'booking-hotel': {
guide: guide('booking-hotel'),
start: openBookings,
steps: [
{
prepare: async p => {
await openManualBooking(p)
await openTypeMenu(p)
},
target: typeMenu,
act: async p => {
await pickType(p, /^Accommodation$/)
await expect(label(p, /^Check-in until$/)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await chooseIn(p, /^Accommodation$/, /^Hotel Granvia Kyoto$/, 'Granvia') },
target: p => block(p, /^Accommodation$/),
act: async p => {
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
await settle(p)
},
},
{
prepare: async p => {
await chooseIn(p, /^From$/, /^Day 5 /)
await chooseIn(p, /^To$/, /^Day 8 /)
},
target: p => row(p, /^To$/),
act: settle,
},
{
prepare: async p => {
await timeBox(p).nth(0).fill('15:00')
await timeBox(p).nth(1).fill('23:00')
await timeBox(p).nth(2).fill('11:00')
await typeInto(p, codeBox(p), 'GRK-40218')
},
target: p => row(p, /^Check-in$/),
act: settle,
},
{
target: p => saveButton(p, 'Add'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteBookings(p, HOTEL_TO_BOOK.name),
},
'link-booking': {
guide: guide('link-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'teamLab Planets timed entry', 'Edit'),
act: p => editFromCard(p, 'teamLab Planets timed entry'),
},
{
prepare: async p => {
await block(p, /^Link to day assignment$/).getByRole('button').first().click()
await expect(selectMenu(p)).toBeVisible()
await beat(p, 300)
},
target: selectMenu,
act: async p => {
await selectMenu(p).getByRole('button', { name: /^2\. teamLab Planets/ }).click()
await expect(selectMenu(p)).toHaveCount(0)
await expect(block(p, /^Link to day assignment$/).getByRole('button', { name: /teamLab Planets/ })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await chooseIn(p, /^Place \/ Activity$/, /^teamLab Planets$/, 'teamLab') },
target: p => block(p, /^Place \/ Activity$/),
act: settle,
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Linked to', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => restore(p, 'teamLab Planets timed entry'),
},
'booking-travelers': {
guide: guide('booking-travelers'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'Fushimi Inari night walk')
await expect(label(p, /^Travelers$/)).toBeVisible()
},
target: p => block(p, /^Travelers$/),
act: settle,
},
{
prepare: async p => {
await block(p, /^Travelers$/).getByRole('button', { expanded: false }).click()
await expect(travelerList(p)).toBeVisible()
await beat(p, 300)
},
target: travelerList,
act: async p => {
const person = travelerList(p).getByRole('button', { name: /jonas/ })
await person.click()
await expect(person).toHaveAttribute('aria-pressed', 'true')
// A click beside the field folds the list away; Escape would close the whole form.
await label(p, /^Travelers$/).click()
await expect(travelerList(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await filterButton(p).click()
await expect(filterMenu(p)).toBeVisible()
await settle(p)
},
// The people are a row of chips under the panel's Travelers caption.
target: p => filterMenu(p).getByRole('button', { name: /jonas$/ }).locator('xpath=..'),
act: async p => {
const person = filterMenu(p).getByRole('button', { name: /jonas$/ })
await person.click()
await expect(person).toHaveAttribute('aria-pressed', 'true')
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
await clearPanelState(p)
await restore(p, 'Fushimi Inari night walk')
},
},
'booking-files': {
guide: guide('booking-files'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'teamLab Planets timed entry')
await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible()
},
target: p => modal(p).getByRole('button', { name: 'Attach file' }),
act: async p => {
// Set the hidden input directly: clicking the button opens the file
// picker of the operating system, which Playwright cannot photograph.
await modal(p).locator('input[type="file"]').setInputFiles(bookingPdfFixture('teamlab-tickets'))
await expect(modal(p).getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => modal(p).getByText('teamlab-tickets.pdf').locator('xpath=..')),
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Link existing file' }).click()
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Link existing file' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true }).click()
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toHaveCount(0)
await expect(modal(p).getByText(UNATTACHED_FILE)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
// Deleting the booking takes the link row to the voucher with it, so the
// voucher is unattached again and Link existing file offers it next time.
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/files`)
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of files.filter(f => f.original_name === 'teamlab-tickets.pdf')) {
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
}
await restore(p, 'teamLab Planets timed entry')
},
},
'booking-cost': {
guide: guide('booking-cost'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'Kyoto Cycling Tour')
await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible()
},
target: p => block(p, /^Costs$/),
act: settle,
},
{
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create expense' }).click()
await expect(p.getByRole('heading', { name: 'Add expense' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await block(p, /^Total amount$/).locator('input').first().fill('12000')
await settle(p)
},
target: p => row(p, /^Total amount$/),
act: settle,
},
only(p => section(p, 'Who paid?')),
{
target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }),
act: async p => {
const save = modal(p).getByRole('button', { name: 'Add expense', exact: true })
// The name comes prefilled from the booking; without it the button is dead.
await expect(save).toBeEnabled()
await save.click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
// The block only reads Linked expenses once the booking is opened again.
await editFromCard(p, 'Kyoto Cycling Tour')
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
// The block sits at the foot of a long form: bring it into the picture.
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await restore(p, 'Kyoto Cycling Tour')
},
},
'filter-bookings': {
guide: guide('filter-bookings'),
start: openBookings,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(resultsChip(p)).toBeVisible()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
await settle(p)
},
// The box around the field, with its magnifier.
target: p => searchBox(p).locator('xpath=..'),
act: async p => {
await searchBox(p).press('Escape')
await expect(searchBox(p)).toHaveValue('')
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
await settle(p)
},
},
{
target: filterButton,
act: async p => {
await filterButton(p).click()
await expect(filterMenu(p)).toBeVisible()
await settle(p)
},
},
{
// The three choices sit on one grey track under the Status caption.
target: p => filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).click()
await expect(bookingCard(p, 'Fushimi Inari night walk')).toHaveCount(0)
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
await settle(p)
},
},
{
target: p => filterMenu(p).getByRole('button', { name: /^Tour\b/ }),
act: async p => {
await filterMenu(p).getByRole('button', { name: /^Tour\b/ }).click()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
await settle(p)
},
},
{
target: resultsChip,
act: async p => {
await resultsChip(p).click()
await expect(resultsChip(p)).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'import-booking-file': {
guide: guide('import-booking-file'),
// The extractor, and no addon: with it answering, the dialog sends mode
// no-ai and no model is asked. The mail has to be an allowed file type or
// the review would save the booking and drop the document without a word.
start: async p => {
await requireExtractor(p.request)
await allowEmlUploads(p.request, true)
await openBookings(p)
},
steps: [
...importSteps(bookingEmlFixture, BOOKING_EML),
{
prepare: async p => {
// The card is there at once, spinning. The picture wants it finished:
// the tick and the Import it offers. The extractor has 30 s in the
// backend, and on a Windows media host that is a docker run, so the
// wait is generous rather than tight.
await expect(importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
await settle(p)
},
target: p => importTask(p, BOOKING_EML),
act: async p => {
await importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible({ timeout: 20_000 })
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
await expect(codeBox(p)).toHaveValue('GRK-40218')
await expect(modal(p).getByText(BOOKING_EML)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('dialog'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await dismissImportTask(p, BOOKING_EML)
await allowEmlUploads(p.request, false)
await clearPanelState(p)
// The route takes the stay and the expense with the booking; the mail it
// attached stays behind in Files like any unlinked document.
await deleteBookings(p, HOTEL_TO_BOOK.name)
await deleteTripFiles(p, BOOKING_EML)
},
},
'edit-booking': {
guide: guide('edit-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'Kyoto Cycling Tour', 'Edit'),
act: p => editFromCard(p, 'Kyoto Cycling Tour'),
},
{
prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') },
target: p => block(p, /^Booking Code$/),
act: settle,
},
{
target: statusPill,
act: async p => {
await expect(statusPill(p)).toHaveAccessibleName('Set to Confirmed')
await statusPill(p).click()
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
await expect(statusPill(p)).toHaveText('Confirmed')
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
// A confirmed card's status dot offers the way back to Pending.
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Set to Pending' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => restore(p, 'Kyoto Cycling Tour'),
},
'delete-booking': {
guide: guide('delete-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'Haneda Airport P4', 'Delete'),
act: async p => {
await cardAction(p, 'Haneda Airport P4', 'Delete').click()
await expect(deleteAsk(p)).toBeVisible()
await settle(p)
},
},
only(deleteAsk),
{
target: p => deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }),
act: async p => {
await deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => ensureBookingsFixtures(p.request),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureBookingsFixtures(request)
})
test('every registered bookings guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripBookingsContext.guides].sort())
})
test('hero: trip-bookings', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripBookingsContext.id, openBookings)
})
for (const id of tripBookingsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+496
View File
@@ -0,0 +1,496 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ordinal, short } from '../dates'
import { SEED_CHAT } from '../screenshots/seed'
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
import { openTrip } from './trip-shared'
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Collab tab of a trip, keyed by the ids in
* `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan",
* whose collab addon already holds three notes, two polls and a seven message
* conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll
* for the one guide that closes a poll for good, and the two upcoming stops
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
* one deliberate exception: a deleted chat message cannot be undeleted, which is
* why `trip-chat` is the last guide of the screen.
*/
const guide = (id: string): HelpGuide => {
const g = tripCollabGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const NOTE = {
title: 'Luggage forwarding',
body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
category: 'Transport',
website: 'https://www.global-yamato.com/en/hc/',
}
/** An .example host never resolves, so the chip shows its own glyph offline too. */
const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' }
const POLL = {
// Plain text on purpose: a bold run in the question would wrap the paragraph
// in one more element and move the card away from `pollCard`'s ancestor depth.
question: 'Which evening do we keep for the kaiseki dinner?',
options: [`${short(-5).split(',')[0]} ${ordinal(-5)}, after Fushimi Inari`, `${short(-4).split(',')[0]} ${ordinal(-4)}, after Arashiyama`, 'Either, decide on the day'],
}
/** The seeded poll nobody has voted on yet. */
const SEEDED_POLL = 'Ryokan or city hotel in Hakone?'
const CHAT = {
mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
emoji: 'Two weeks to go ',
flag: '🎌',
theirs: 'Drop bags. I want to be at Senso-ji before the crowds.',
answer: 'Agreed, bags at the hotel first, then Asakusa.',
reactTo: SEED_CHAT.teamlab,
reaction: '👍',
}
// ── Locators ─────────────────────────────────────────────────────────────────
/**
* One of the five cards of the tab, found from the name in its head band. The
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
* the match ignores case; the card is the nearest rounded ancestor (the cards
* inside a panel, notes and polls, are never an ancestor of that heading).
*/
const panel = (page: Page, name: RegExp) =>
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
const linksPanel = (page: Page) => panel(page, /^Links$/i)
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
/** The row that holds the emoji button, the image button, the box and the arrow. */
const composer = (page: Page) => chatBox(page).locator('xpath=..')
/**
* The quoted message above the box while a reply is being written. With no reply
* pending the same position is the composer row itself, which is what the step
* after the send asserts against.
*/
const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first()
/** The send button and the emoji button carry no text and no label at all. */
const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)')
const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)')
/** The emoji picker is a portal; its three category tabs are the sure thing in it. */
const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..')
/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */
const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last()
/** One message, by the relative wrapper that also carries its Reply and Delete. */
const bubble = (page: Page, text: string) =>
page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]')
/**
* Put the pointer on a message's Reply or Delete. The row holding them is
* `pointer-events: none` until its own bubble is hovered, and it sits at
* `top: -14px`, outside that bubble's box: hovering the button alone would land
* on whatever is behind it. `captureGuide` scrolls the target to the middle of
* the frame between `prepare` and the shot, and that scroll can take the bubble
* out from under the pointer, so the hover is made again here, bubble first.
*/
const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise<void> => {
await page.getByText(text).first().hover()
await beat(page, 300)
await bubble(page, text).getByRole('button', { name }).hover()
}
/**
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
* field only each of them has. The whole panel is the dialog: the head band
* with the title typed into it, the body, and the footer with the buttons.
*/
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
/** A field of an editor with the eyebrow label over it: the box's parent. */
const field = (box: Locator) => box.locator('xpath=..')
/** A note card is an article named by the note's title. */
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
/** A poll card is an article too, without a name: found from its question. */
const pollCard = (page: Page, question: string) =>
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
/** A What's Next row, from the place name in it: name → details → row. */
const nextRow = (page: Page, place: string) =>
nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..')
// ── Start and cleanup ────────────────────────────────────────────────────────
const start = async (page: Page): Promise<void> => {
await openTrip(page, { tab: 'collab' })
// The Collab panel is a lazy chunk: everything below needs it mounted.
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
async function listOf<T>(page: Page, path: string, key: string): Promise<T[]> {
const res = await page.request.get(`${collab()}/${path}`)
const body = (await res.json()) as Record<string, T[] | undefined>
return body[key] ?? []
}
async function deleteNote(page: Page, title: string): Promise<void> {
const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes')
for (const note of notes.filter(n => n.title === title)) {
await page.request.delete(`${collab()}/notes/${note.id}`)
}
}
async function deleteLink(page: Page, title: string): Promise<void> {
const links = await listOf<{ id: number; title: string }>(page, 'links', 'links')
for (const link of links.filter(l => l.title === title)) {
await page.request.delete(`${collab()}/links/${link.id}`)
}
}
async function deletePoll(page: Page, question: string): Promise<void> {
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
for (const poll of polls.filter(p => p.question === question)) {
await page.request.delete(`${collab()}/polls/${poll.id}`)
}
}
/**
* Voting for an option you already chose deletes the row, so one call puts the
* seeded, unvoted poll back the way the next guide expects it.
*/
async function resetVote(page: Page, question: string, optionIndex: number): Promise<void> {
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
const poll = polls.find(p => p.question === question)
if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } })
}
const only = (target: (page: Page) => Locator) => ({ target })
// ── Scripts ──────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'write-note': {
guide: guide('write-note'),
start,
steps: [
{
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
act: async page => {
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
await expect(noteDialog(page)).toBeVisible()
await settle(page)
},
},
{
// The title is typed into the dialog's head band, under the New Note eyebrow.
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
},
{
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
// The Content eyebrow and the box under it.
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
},
{
// Picked before the shot, so the pill and the head band already wear its colour.
prepare: async page => {
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
await beat(page, 300)
},
// The Category eyebrow and the row of pills: pill, row, field.
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
},
{
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
},
{
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
act: async page => {
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
cleanup: page => deleteNote(page, NOTE.title),
},
'shared-links': {
guide: guide('shared-links'),
start,
steps: [
{
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
act: async page => {
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
await expect(linkDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: async page => {
// The title sits in the head band, the address in the Link field under it.
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
await beat(page, 300)
},
target: linkDialog,
act: async page => {
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => { await linkChip(page, LINK.title).hover() },
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'),
},
{
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'),
act: async page => {
// Exact: an accessible name is matched as a substring, and "Unpin link"
// holds "Pin link", so the loose form would find both buttons.
await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click()
await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/)
await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title)
await settle(page)
},
},
],
cleanup: page => deleteLink(page, LINK.title),
},
'create-poll': {
guide: guide('create-poll'),
start,
steps: [
{
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
act: async page => {
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
await expect(pollDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
// The Question eyebrow, the box and the Markdown supported hint under it.
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
},
{
prepare: async page => {
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
},
// The Options field: box, its row, the column of rows, the field with its eyebrow.
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
},
{
// The label drops the string's own "+"; the button draws a plus icon instead.
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
act: async page => {
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
await beat(page, 300)
},
},
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
{
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
act: async page => {
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
cleanup: page => deletePoll(page, POLL.question),
},
'vote-poll': {
guide: guide('vote-poll'),
start,
steps: [
{
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }),
act: async page => {
await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click()
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
// Both bars and both percentages, so the step about reading them has them.
only(page => pollCard(page, SEEDED_POLL).locator('> div').last()),
{
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }),
act: async page => {
await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click()
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 })
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible()
await settle(page)
},
},
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
],
cleanup: page => resetVote(page, SEEDED_POLL, 0),
},
'close-poll': {
guide: guide('close-poll'),
start,
steps: [
{
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
act: async page => {
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
{
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
act: async page => {
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
],
// The last step normally already removed it; this is for a run that stopped short.
cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question),
},
'whats-next': {
guide: guide('whats-next'),
start,
steps: [
only(nextPanel),
// The time column is the first child of a row; the name, the address and the chips are its last.
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
],
// Read-only panel: the guide writes nothing.
},
'trip-chat': {
guide: guide('trip-chat'),
start,
steps: [
{
prepare: page => typeInto(page, chatBox(page), CHAT.mine),
target: composer,
act: async page => {
await chatBox(page).press('Enter')
await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await typeInto(page, chatBox(page), CHAT.emoji)
await emojiButton(page).click()
await expect(emojiPicker(page)).toBeVisible()
await beat(page, 300)
},
target: emojiPicker,
act: async page => {
await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click()
await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click()
await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`)
await sendButton(page).click()
await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
// The hover row is pointer-events:none until the bubble under it is hovered,
// so the bubble has to be hovered before the button can be reached at all.
prepare: async page => {
await page.getByText(CHAT.theirs).first().hover()
await beat(page, 300)
},
target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }),
hover: hoverMessageAction(CHAT.theirs, 'Reply'),
act: async page => {
await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click()
await expect(replyQuote(page)).toContainText(CHAT.theirs)
await settle(page)
},
},
{
prepare: page => typeInto(page, chatBox(page), CHAT.answer),
target: replyQuote,
act: async page => {
await chatBox(page).press('Enter')
await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 })
await expect(replyQuote(page)).not.toContainText(CHAT.theirs)
await settle(page)
},
},
{
prepare: async page => {
await page.getByText(CHAT.reactTo).first().click({ button: 'right' })
await expect(reactionMenu(page)).toBeVisible()
await beat(page, 300)
},
target: reactionMenu,
act: async page => {
await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click()
await expect(
bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }),
).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await page.getByText(CHAT.mine).first().hover()
await beat(page, 300)
},
target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }),
hover: hoverMessageAction(CHAT.mine, 'Delete'),
act: async page => {
await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click()
// The bubble shrinks for 400 ms before the request goes out.
await page.waitForTimeout(1200)
await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
// No cleanup, deliberately: deleting a message is a soft delete and there is
// no undelete over the API, so undoing anything here would only add more
// tombstones. This is why trip-chat is the last guide of the screen.
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureCollabFixtures(request)
})
test('every registered collab guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort())
})
test('hero: trip-collab', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripCollabContext.id, start)
})
for (const id of tripCollabContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+736
View File
@@ -0,0 +1,736 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { day, long, short } from '../dates'
import { seededTrip, ensureCostsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Costs tab of a trip, keyed by the ids in
* `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan",
* which keeps its books in JPY with one expense in EUR and one transfer already
* recorded, so the frozen rate and the settle-up column both have something
* real to show. What a guide writes it takes away again in `cleanup`, the
* settlements included, because the next guide reads the same balances.
*/
const guide = (id: string): HelpGuide => {
const g = tripCostsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */
const UNPAID = 'Airport transfer (Narita Express)'
/** The seeded expense in euros, the one carrying a frozen rate. */
const SEEDED_EUR = 'Flights FRA → HND'
/** The seeded flight booking, on the Transports tab because its type is flight. */
const BOOKING = 'LH716 FRA → HND'
/** A seeded expense costs-table gives its days to. */
const JR_PASS = 'JR Pass (14 days)'
const DINNER = 'Kaiseki dinner in Gion'
const UPGRADE = 'Shinkansen seat upgrade'
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: day(-4) }
/** The row costs-table adds to Food & drink; the table names a new row this until it is renamed. */
const MATCHA = 'Matcha in Uji'
const NEW_ROW = 'New Entry'
/** Where the tab keeps the list/table choice (CostsPanel's COSTS_VIEW_KEY). */
const VIEW_KEY = 'trek:costs-view'
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
const openCosts = async (p: Page): Promise<void> => {
await openTrip(p, { tab: 'finanzplan' })
await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 })
await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 })
await settle(p)
}
/** A row of the ledger by the expense's name. */
const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first()
/**
* The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child,
* so a locator scoped to the row itself would never find it.
*/
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
/**
* The transfer settle-up records for jonas, in the display currency. The seed
* already holds one from jonas, in euros, which carries the converted line;
* leaving that one out keeps the guide off the seed's own payment.
*/
const newPayRow = (page: Page) =>
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: 'jonas' }).filter({ hasNotText: '€' }).first()
/**
* A block of the expense editor, found by its own label: the <section> of
* Who paid? and Split, the grey group that holds Total amount, Currency and Day.
*/
const block = (page: Page, label: string) =>
modal(page)
.getByText(label, { exact: true })
.first()
.locator('xpath=ancestor::*[self::section or contains(@class,"rounded-[16px]")][1]')
/** A card of the right-hand column: each is a <section> named by its heading. */
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
/** The name, typed into the head of the expense editor. */
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
/** The category pill under the name, and the list it opens (a portal named after it). */
const categoryPill = (page: Page) => modal(page).getByRole('button', { name: /^Category:/ })
const categoryList = (page: Page) => page.getByRole('group', { name: 'Category', exact: true })
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
/**
* One suggested transfer in the Settle up card, by the initial on its first
* avatar ("J" for jonas; the names are only in the tooltip). The card lists the
* flows in whatever order the settlement endpoint returns them, so taking the
* first Settle button would settle whichever transfer happened to come first.
*/
const settleRow = (page: Page, initial: string) =>
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
// ── The bar's filters ────────────────────────────────────────────────────────
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
const filterButton = (page: Page) => page.getByRole('button', { name: 'Filter', exact: true })
/** The popover the funnel opens: the owner switch, the categories and the days. */
const filterMenu = (page: Page) => page.getByRole('menu')
async function openFilter(page: Page): Promise<void> {
if (!(await filterMenu(page).isVisible().catch(() => false))) await filterButton(page).click()
await expect(filterMenu(page)).toBeVisible()
await beat(page, 300)
}
async function closeFilter(page: Page): Promise<void> {
if (await filterMenu(page).isVisible().catch(() => false)) await filterButton(page).click()
await expect(filterMenu(page)).toHaveCount(0)
}
// ── The table view ───────────────────────────────────────────────────────────
const table = (page: Page) => page.getByRole('table')
/** A category's group of the table: a <tbody> opened by a button with its name and count. */
const group = (page: Page, category: string) =>
table(page)
.locator('tbody')
.filter({ has: page.getByRole('button', { name: new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\d+$`) }) })
const tableRow = (page: Page, name: string) => table(page).getByRole('row').filter({ hasText: name }).first()
const summary = (page: Page) => page.getByRole('region', { name: 'Summary', exact: true })
const only = (target: (p: Page) => Locator) => ({ target })
/**
* A dialog that closes hands the focus back to the button that opened it, and
* a focused button wears its outline and its tooltip, the tooltip at wherever
* the button stood before the next picture scrolled: both would stand in that
* picture as if they were part of it.
*/
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
await dropFocus(page)
}
}
type BudgetRow = { id: number; name: string; days?: number | null }
/** What the JR Pass's Days held before costs-table typed into it; cleanup puts it back. */
let passDaysBefore: number | null = null
async function budgetItems(page: Page): Promise<BudgetRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/budget`)
return ((await res.json()) as { items: BudgetRow[] }).items
}
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const item of (await budgetItems(page)).filter(i => names.includes(i.name))) {
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
}
}
async function createExpense(
page: Page,
item: { name: string; category: string; total_price: number; currency: string; expense_date: string },
): Promise<void> {
const { tripId, memberIds } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/budget`, {
data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] },
})
if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`)
}
async function settlementIds(page: Page): Promise<number[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`)
const { settlements } = (await res.json()) as { settlements: { id: number }[] }
return settlements.map(s => s.id)
}
/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */
let transfersBefore: number[] = []
/** Open the expense editor's currency list, search for a code and take it. */
async function pickCurrency(page: Page, code: string): Promise<void> {
await modal(page).getByRole('button', { name: /^JPY/ }).first().click()
await beat(page, 300)
await dropdown(page).locator('input').first().fill(code)
await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click()
await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible()
await settle(page)
}
const SCRIPTS: Record<string, GuideScript> = {
'add-expense': {
guide: guide('add-expense'),
start: openCosts,
steps: [
{
target: addExpense,
act: async p => {
await addExpense(p).click()
await expect(nameBox(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, nameBox(p), DINNER)
await settle(p)
},
target: nameBox,
},
{
// The list open under the pill, so the picture shows what there is to pick.
prepare: async p => {
await categoryPill(p).click()
await expect(categoryList(p)).toBeVisible()
await beat(p, 300)
},
target: categoryPill,
act: async p => {
await categoryList(p).getByRole('button', { name: 'Food & drink' }).click()
await expect(categoryList(p)).toHaveCount(0)
await expect(categoryPill(p)).toHaveAccessibleName(/Food & drink/)
await settle(p)
},
},
{
prepare: async p => {
await amounts(p).first().fill('18600')
await expect(saveExpense(p)).toBeEnabled()
await settle(p)
},
// The grey group: Total amount, then Currency and Day beside it.
target: p => block(p, 'Total amount'),
},
only(p => block(p, 'Who paid?')),
{
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteExpenses(p, DINNER),
},
'expense-payers': {
guide: guide('expense-payers'),
start: openCosts,
steps: [
{
prepare: async p => {
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Who paid?')).toBeVisible()
await settle(p)
},
target: p => block(p, 'Who paid?'),
},
// Ringed, not clicked: the Ryokan keeps its payer.
only(p => modal(p).getByRole('radio', { name: 'No one paid yet' })),
{
prepare: async p => {
await block(p, 'Who paid?').getByRole('button', { name: 'Multiple people paid' }).click()
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
await modal(p).getByTestId('payer-toggle').nth(1).click()
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
await settle(p)
},
target: p => block(p, 'Who paid?'),
act: async p => {
await block(p, 'Who paid?').getByRole('button', { name: 'One person paid' }).click()
await closeModal(p)
await settle(p)
},
},
{
target: p => expRow(p, UNPAID),
// On the flag, whose tooltip says what it means; the row's centre is the note.
hover: async p => {
await expRow(p, UNPAID).getByText('Unfinished').hover()
},
act: async p => {
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
},
},
],
},
'split-expense': {
guide: guide('split-expense'),
start: async p => {
await createExpense(p, LUNCH)
await openCosts(p)
},
steps: [
{
prepare: async p => {
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Split')).toBeVisible()
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'false')
await settle(p)
},
target: p => block(p, 'Split'),
act: async p => {
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
// The badges under the list: how many share it, and what one share is.
only(p => block(p, 'Split').getByText(/ per person$/).locator('xpath=..')),
{
prepare: async p => {
await block(p, 'Split').getByRole('button', { name: 'Custom', exact: true }).click()
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(0).fill('1000')
await expect(block(p, 'Split').getByText(/^Sum of splits: /)).toBeVisible()
await settle(p)
},
target: p => block(p, 'Split').getByText(/^Sum of splits: /),
act: async p => {
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(1).fill('1900')
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(2).fill('1900')
await expect(block(p, 'Split').getByText('Split matches total')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const split = block(p, 'Split')
await split.getByRole('button', { name: 'Ticket', exact: true }).click()
await split.getByRole('button', { name: 'Add item' }).click()
await typeInto(p, split.getByPlaceholder('Item name').first(), 'Tamagoyaki')
await split.locator('input[inputmode="decimal"]').nth(0).fill('1200')
await split.getByRole('button', { name: 'Add item' }).click()
await typeInto(p, split.getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
await split.locator('input[inputmode="decimal"]').nth(1).fill('900')
// The second line is shared by two of the three travelers.
await split
.getByPlaceholder('Item name')
.nth(1)
.locator('xpath=ancestor::div[2]')
.getByRole('checkbox', { name: /jonas/ })
.click()
await expect(amounts(p).first()).toHaveValue('2100.00')
await settle(p)
},
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
},
{
target: p => modal(p).getByText('Individual shares', { exact: true }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
],
cleanup: p => deleteExpenses(p, LUNCH.name),
},
'expense-currency': {
guide: guide('expense-currency'),
start: openCosts,
steps: [
{
prepare: async p => {
await addExpense(p).click()
await expect(nameBox(p)).toBeVisible()
await typeInto(p, nameBox(p), UPGRADE)
await amounts(p).first().fill('48')
// The editor opens on Food & drink; a seat upgrade filed under it would
// read wrong on the ledger row this guide's last picture is of.
await categoryPill(p).click()
await categoryList(p).getByRole('button', { name: 'Transport', exact: true }).click()
await expect(categoryPill(p)).toHaveAccessibleName(/Transport/)
await settle(p)
},
target: p => block(p, 'Total amount'),
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: /^JPY/ }).first().click()
await beat(p, 300)
await dropdown(p).locator('input').first().fill('EUR')
await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible()
},
target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(),
act: async p => {
await dropdown(p).getByRole('button', { name: /^EUR/ }).click()
await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible()
await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'),
act: async p => {
// Both sides have to be there: an offline run would show the amount unconverted.
await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/)
},
},
{
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => expRow(p, UPGRADE),
act: async p => {
await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
},
},
],
cleanup: p => deleteExpenses(p, UPGRADE),
},
'filter-costs': {
guide: guide('filter-costs'),
start: openCosts,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'pass')
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
await settle(p)
},
target: p => searchBox(p).locator('xpath=..'),
act: async p => {
await searchBox(p).fill('')
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
await settle(p)
},
},
{
// The owner switch at the top of the popover, on Paid by me.
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).click()
await expect(filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
target: p => filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'All', exact: true }).click()
await settle(p)
},
},
{
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Transport', exact: true }).click()
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
await settle(p)
},
// The category list, with the pick marked and the funnel's count above it.
target: p => filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).click()
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: short(-9), exact: true }).click()
// Folded away, or the popover would sit over the banner it produced.
await closeFilter(p)
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toBeVisible()
await settle(p)
},
target: p => p.getByText(new RegExp(`^${long(-9)}$`)).locator('xpath=../..'),
act: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Reset filters' }).click()
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toHaveCount(0)
await closeFilter(p)
await settle(p)
},
},
// Clicking it would start a download; the button is what the step is about.
only(p => p.getByRole('button', { name: 'Export CSV' })),
],
},
'settle-up': {
guide: guide('settle-up'),
start: async p => {
await openCosts(p)
transfersBefore = await settlementIds(p)
},
steps: [
only(p => card(p, 'Settle up')),
{
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
act: async p => {
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(newPayRow),
{
target: p => newPayRow(p).locator('xpath=..').locator('.exp-actions'),
act: async p => {
await newPayRow(p).locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
await settle(p)
},
target: p => p.getByRole('dialog', { name: 'Add payment' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add payment' }).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
// Ringed and left alone: one click would record every open transfer and
// flatten the balances every later picture is taken against.
only(p => p.getByRole('button', { name: 'Settle up', exact: true })),
],
cleanup: async p => {
const { tripId } = seededTrip()
for (const id of await settlementIds(p)) {
if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`)
}
},
},
'final-budget': {
guide: guide('final-budget'),
start: openCosts,
steps: [
only(p => card(p, 'Balances')),
only(p => card(p, 'Final budget')),
{
prepare: async p => {
await card(p, 'Final budget').getByRole('button').first().click()
await expect(p.getByText('Expenses paid').first()).toBeVisible()
await settle(p)
},
target: p => card(p, 'Final budget').locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
},
{
// The second "Expenses paid" is the heading of the list the figure is made of.
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
act: async p => {
await card(p, 'Final budget').getByRole('button').first().click()
await expect(card(p, 'Final budget').locator('[aria-expanded="true"]')).toHaveCount(0)
},
},
],
},
'expense-from-booking': {
guide: guide('expense-from-booking'),
start: async p => {
await openTrip(p, { tab: 'transports' })
await expect(p.getByRole('article', { name: BOOKING }).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
},
steps: [
{
target: p => p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
await settle(p)
},
},
only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')),
{
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create expense' }).click()
await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await amounts(p).first().fill('890')
await pickCurrency(p, 'EUR')
},
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
// The block sits at the foot of a long form: bring it into the picture.
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await deleteExpenses(p, BOOKING)
},
},
'costs-table': {
guide: guide('costs-table'),
start: async p => {
// What the pass's Days cell holds before the guide types into it.
const pass = (await budgetItems(p)).find(i => i.name === JR_PASS)
if (!pass) throw new Error(`the seed has no "${JR_PASS}"`)
passDaysBefore = pass.days ?? null
await openCosts(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Table', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Table', exact: true }).click()
await expect(table(p)).toBeVisible({ timeout: 15_000 })
await expect(tableRow(p, SEEDED_EUR)).toBeVisible()
await settle(p)
},
},
// One category as a whole: its head with the count and the subtotal, its rows, its add button.
only(p => group(p, 'Transport')),
{
// The pass is good for fourteen days, and its Days cell is still empty:
// typed in, Per Day and P. p / Day fill in beside it.
prepare: async p => {
await tableRow(p, JR_PASS).getByRole('button', { name: /^Days/ }).click()
const field = tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' })
await expect(field).toBeFocused()
await field.fill('14')
await settle(p)
},
target: p => tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }),
act: async p => {
await tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }).press('Enter')
await expect(tableRow(p, JR_PASS).getByRole('button', { name: 'Days: 14' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// Hovered for the picture, so the lock's reason stands above it.
target: p => tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }),
act: async p => {
await tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }).click()
await expect(p.getByRole('dialog', { name: 'Edit expense' })).toBeVisible()
await closeModal(p)
await settle(p)
},
},
{
target: p => group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }),
act: async p => {
await group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }).click()
const name = table(p).getByRole('textbox', { name: 'Name' })
await expect(name.or(tableRow(p, NEW_ROW)).first()).toBeVisible({ timeout: 15_000 })
if (!(await name.isVisible().catch(() => false))) {
await tableRow(p, NEW_ROW).getByRole('button', { name: /^Name/ }).click()
}
await name.fill(MATCHA)
await name.press('Enter')
await expect(tableRow(p, MATCHA)).toBeVisible({ timeout: 15_000 })
// A total too, the same way, so the row reads like a real one.
await tableRow(p, MATCHA).getByRole('button', { name: /^Total/ }).click()
const total = tableRow(p, MATCHA).getByRole('textbox', { name: 'Total' })
await total.fill('1800')
await total.press('Enter')
await expect(tableRow(p, MATCHA).getByRole('button', { name: /^Total: .*1,800/ })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await summary(p).getByRole('button', { name: 'Payer', exact: true }).click()
await expect(summary(p).getByText('No payer yet')).toBeVisible()
await settle(p)
},
target: summary,
},
],
// Back to the ledger, so every other guide finds it, and the seed as it was.
cleanup: async p => {
const list = p.getByRole('button', { name: 'List', exact: true })
if (await list.isVisible().catch(() => false)) await list.click()
await p.evaluate(key => { try { localStorage.setItem(key, 'list') } catch { /* no storage: nothing to put back */ } }, VIEW_KEY)
await deleteExpenses(p, MATCHA, NEW_ROW)
const { tripId } = seededTrip()
for (const item of (await budgetItems(p)).filter(i => i.name === JR_PASS)) {
await p.request.put(`/api/trips/${tripId}/budget/${item.id}`, { data: { days: passDaysBefore } })
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureCostsFixtures(request)
})
test('every registered costs guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort())
})
test('hero: trip-costs', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripCostsContext.id, openCosts)
})
for (const id of tripCostsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+363
View File
@@ -0,0 +1,363 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures'
import { openTrip } from './trip-shared'
import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the day details panel of a trip, keyed by the ids in
* `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in
* Japan" with the day fixtures in place, so day 1 carries the flight and the
* Tokyo hotel and day 5 the train and its evening walk; what a guide writes it
* takes back in `cleanup`, so the next one starts from the same seed.
*/
const guide = (id: string): HelpGuide => {
const g = tripDayDetailGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The name the rename guide gives day 5, and takes away again. */
const DAY_NAME = 'Arrival in Kyoto'
/** The panel itself: the only `fixed z-50` in the client. */
const panel = (page: Page) => page.locator('div.fixed.z-50')
/** A day's header in the days column; its name is number, weather, title, date. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* The hotel picker is a portal of its own with neither `trek-modal-backdrop`
* nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it;
* the card is the second `role="presentation"` and holds the day range.
*/
const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' })
/** The row of the stay's three times, with Confirmation right under it. */
const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]')
/**
* A block of the panel by its heading. Every block is a <section> whose first
* row carries the heading, so the section is the block with everything in it.
*/
const block = (page: Page, heading: string) =>
panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
/** The accommodation block: the stays and the add button. */
const stays = (page: Page) => block(page, 'Accommodation')
/** One booked stay: an <article> card inside that block. */
const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first()
/** The caption that opens the weather block. */
const forecast = (page: Page) => panel(page).getByText(/^Forecast for /)
/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */
const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]')
/** The summary row's numbers: the temperature, the low and high, the condition. */
const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]')
/** The hourly strip under its own heading. */
const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..')
/** The day's reservations block. */
const bookings = (page: Page) => block(page, 'Reservations')
/** One booking row: on the desktop a button that opens the booking, named with its title. */
const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title })
/** The booking a row opens, named by its title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/**
* Select a day by its title rather than by the middle of its header: a day with
* a night booked over it carries a hotel button there, and that button opens the
* place instead of selecting the day.
*/
const selectDay = async (page: Page, n: number): Promise<void> => {
await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click()
await expect(panel(page)).toBeVisible()
await settle(page)
}
/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */
const openDay = async (page: Page, n: number): Promise<void> => {
await openTrip(page, { day: null })
await selectDay(page, n)
}
interface Stay {
id: number
place_id: number | null
place_name?: string | null
start_day_id: number
end_day_id: number
}
async function stayOf(page: Page, name: string): Promise<Stay | undefined> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/accommodations`)
const { accommodations } = (await res.json()) as { accommodations: Stay[] }
return accommodations.find(a => a.place_name === name)
}
/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */
async function removeStay(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const stay = await stayOf(page, name)
if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`)
}
/** The update route reads every field off the body, so the whole stay goes back. */
async function restoreTokyoStay(page: Page): Promise<void> {
const { tripId } = seededTrip()
const stay = await stayOf(page, TOKYO_HOTEL)
if (!stay) return
await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, {
data: {
place_id: stay.place_id,
start_day_id: stay.start_day_id,
end_day_id: stay.end_day_id,
check_in: '15:00',
check_in_end: null,
check_out: '11:00',
confirmation: 'GRC-7731',
},
})
}
/**
* Give day 5 its number back: `selectDay` finds a day by "5 … Day 5 ", which a
* renamed day no longer answers to, and every later guide uses it.
*/
async function resetDayTitle(page: Page, index: number): Promise<void> {
const { tripId, dayIds } = seededTrip()
await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } })
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'day-panel': {
guide: guide('day-panel'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => dayHeader(p, 1),
act: async p => {
await selectDay(p, 1)
await expect(stays(p)).toBeVisible()
},
},
only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')),
{
// The forecast is asked for when the panel opens, and it is a round trip
// to Open-Meteo; without the wait the picture catches the spinner.
prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) },
target: p => forecast(p).locator('xpath=..'),
},
only(bookings),
only(stays),
{
target: p => panel(p).getByRole('button', { name: 'Collapse' }),
act: async p => {
// Every day card carries a chevron with the same label, so the panel's
// own is reached through the panel.
await panel(p).getByRole('button', { name: 'Collapse' }).click()
await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible()
await expect(stays(p)).toBeHidden()
await settle(p)
},
},
],
},
'day-weather': {
guide: guide('day-weather'),
start: async p => {
await openDay(p, 5)
// Day 5 has one located stop, so the forecast is that stop's.
await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 })
},
steps: [
only(forecast),
only(summary),
only(chips),
only(hourly),
],
},
'rename-day': {
guide: guide('rename-day'),
start: p => openDay(p, 5),
steps: [
only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')),
{
// Every booking row of the open day carries an Edit of its own in the
// days column (DayPlanSidebar), so the header's pencil is reached
// through the panel; inside it, it is the only one.
target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await panel(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) },
target: p => panel(p).getByPlaceholder('Day 5'),
act: async p => {
await p.keyboard.press('Enter')
await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible()
await settle(p)
},
},
only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })),
],
cleanup: p => resetDayTitle(p, 4),
},
'add-accommodation': {
guide: guide('add-accommodation'),
start: p => openTrip(p, { day: null }),
steps: [
// The fixture put the hotel in the trip as a place; the picker offers
// nothing else, and the rows of the column are options.
only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })),
{
prepare: async p => { await selectDay(p, 5) },
target: p => p.getByRole('button', { name: 'Add accommodation' }),
act: async p => {
await p.getByRole('button', { name: 'Add accommodation' }).click()
await expect(picker(p)).toBeVisible()
await settle(p)
},
},
{
// The two day selects are named Start and End; their options are
// portalled to the body, outside the picker.
prepare: async p => {
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
await p.getByRole('button', { name: /^Day 7/ }).last().click()
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
await settle(p)
},
// The row of Start, End and All: the All button's parent.
target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
},
{
// The three time fields normalise what is typed; the labels carry no
// `for`, so every field is found by its placeholder.
prepare: async p => {
await picker(p).getByPlaceholder('14:00').fill('15:00')
await picker(p).getByPlaceholder('22:00').fill('20:00')
await picker(p).getByPlaceholder('11:00').fill('11:00')
await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842')
await settle(p)
},
target: times,
},
{
// The category is a pill over the list; its choices open in a list of their own.
prepare: async p => {
await picker(p).getByRole('button', { name: /^Category:/ }).click()
await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click()
await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0)
await settle(p)
},
target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }),
act: async p => {
await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click()
await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled()
},
},
{
target: p => picker(p).getByRole('button', { name: 'Save' }),
act: async p => {
await picker(p).getByRole('button', { name: 'Save' }).click()
await expect(picker(p)).toHaveCount(0)
await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => removeStay(p, KYOTO_HOTEL.name),
},
'edit-accommodation': {
guide: guide('edit-accommodation'),
start: async p => {
// The day fixtures book the Tokyo hotel over the first nights, so day 1
// is its check-in day.
await openDay(p, 1)
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
},
steps: [
only(p => stayCard(p, TOKYO_HOTEL)),
{
// Neither the pencil nor the × carries a label; the icon is the handle.
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'),
act: async p => {
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await picker(p).getByPlaceholder('11:00').fill('10:00')
await picker(p).getByPlaceholder('ABC-12345').click()
await settle(p)
},
target: times,
},
{
target: p => picker(p).getByRole('button', { name: 'Save' }),
act: async p => {
await picker(p).getByRole('button', { name: 'Save' }).click()
await expect(picker(p)).toHaveCount(0)
// Day 1 is the arrival: the card shows the check-in window, not the new check-out.
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// Deliberately not performed: the × removes the stay without a question,
// and with it the seed's hotel booking and the stop it put on the day.
prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() },
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'),
},
],
cleanup: restoreTokyoStay,
},
'day-bookings': {
guide: guide('day-bookings'),
start: p => openDay(p, 5),
steps: [
only(bookings),
only(p => bookingRow(p, EVENING_WALK)),
only(p => bookingRow(p, 'Nozomi 21')),
{
// A row opens its booking over the plan; Edit at its foot is where it changes.
prepare: async p => {
await bookingRow(p, 'Nozomi 21').click()
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureDayDetailFixtures(request)
})
test('every registered day-detail guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort())
})
test('hero: trip-day-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
// Day 1 is the one day that has all four blocks at once: the forecast for its
// first stop, the flight, and the hotel it checks into.
await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1))
})
for (const id of tripDayDetailContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+550
View File
@@ -0,0 +1,550 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureDaysFixtures } from './fixtures'
import { openTrip, openTripOnDay, modal, portalDialog, selectDay, closeDayDetails, closeMenu, dayDetails } from './trip-shared'
import { tripDaysContext, tripDaysGuides } from '../../src/help/contexts/tripDays'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the days column of a trip, keyed by the ids in
* `src/help/contexts/tripDays.ts`. The seeded trip gets a timed stop, a
* reservation at a stop, a train and a hotel first, so the cards show every
* kind of row; a guide that changes the plan puts it back in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripDaysGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
/** The note the day-note guide writes: a name for the card, markdown underneath. */
const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' }
/** A day card's header button. */
/**
* The day the route guides read. Day 6 is the only one whose two stops sit next
* to each other: day 1's are separated by the flight, so it has no leg of its
* own and its only connector is the one to the booked night, which depends on a
* fixture. Day 6 also carries a reservation at a stop, so one card shows every
* row the guide talks about.
*/
const ROUTED_DAY = 6
/** The pencil that renames the open day, in its details panel. */
const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true })
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* A stop row. Every row of a day card is a div with `role="button"`; a stop is
* the one whose accessible name opens with the label of the lock on its
* picture, which is what tells it from the note and booking rows beside it.
*/
const stop = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first()
/** A booking row of a day: its phase (Departure / Arrival) and title make its name. */
const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name })
/** The note the day-note guide left on day 2, as a row of the card. */
const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) })
/** A row of the places column. */
const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** The "+ Day" at the end of a places row: it puts the place on the open day. */
const toOpenDay = (page: Page, name: string) => placeRow(page, name).getByRole('button', { name: '+ Day' })
/** The "+" in a day's head band; the note, the transports and the stay are in the menu it opens. */
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
/**
* The context menu of a stop, a note or a leg. Tooltip renders into the same
* `.trek-popover-enter` class and a click on a connector leaves its tooltip
* standing, so the menu is the popover that is not one.
*/
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
/** The route bar of the open day: Route, the two hand-offs, Optimize, the modes. */
const routeBar = (page: Page) => page.getByRole('button', { name: 'Route', exact: true }).locator('xpath=..')
const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).locator('xpath=..')
/**
* A connector between two stops, once the day's route is computed. The hotel
* bookend legs carry the same label and are not between two stops, so they are
* filtered out by the hotel icon only they have.
*/
/**
* A leg between two rows of a day, ringed by its travel time. Day 1's is the one
* to the booked night: the flight sits between its two stops, so they have no
* leg of their own, and the day fixtures put a hotel on the first four nights.
*/
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
/** The note dialog, known by its body field. */
const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…')
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) })
/** The question a timed stop asks before it moves; also a portal of its own. */
const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?'))
/** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */
const reorderPopup = (page: Page) => page.locator('.trek-modal-enter').filter({ hasText: 'Reorder days' })
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => names.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/**
* Open the trip on day 1 and draw its route, so the connectors between the
* stops exist. The day stays selected with its details panel up: the panel is
* the selection, and the route tools render for the selected day only.
*/
async function openWithRoute(page: Page): Promise<void> {
await openTripOnDay(page, ROUTED_DAY)
await page.getByRole('button', { name: 'Route', exact: true }).click()
await expect(connector(page)).toBeVisible({ timeout: 30_000 })
await settle(page)
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
/** The menu a right-click on a day's head opens. */
/** The question before a day is cleared (a ConfirmDialog, which carries no dialog role). */
const clearDialog = (page: Page) => page.locator('.trek-modal-enter').filter({ has: page.getByRole('heading', { name: /^Clear .+\?$/ }) }).last()
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
const SCRIPTS: Record<string, GuideScript> = {
'clear-day': {
guide: guide('clear-day'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await dayHeader(p, 2).click({ button: 'right' })
await expect(dayMenu(p)).toBeVisible()
await beat(p, 300)
},
target: dayMenu,
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Clear day' }).click()
await expect(p.getByRole('heading', { name: /^Clear .+\?$/ })).toBeVisible()
await settle(p)
},
},
{
target: clearDialog,
act: async p => {
await clearDialog(p).getByRole('button', { name: 'Clear day' }).click()
await expect(clearDialog(p)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
// Undo puts the seed's stops back on the day for the guides after this one.
const undo = p.getByRole('button', { name: 'Undo' }).last()
if (await undo.isVisible()) await undo.click()
await settle(p)
},
},
'read-day-plan': {
guide: guide('read-day-plan'),
start: openWithRoute,
steps: [
only(p => dayHeader(p, ROUTED_DAY)),
only(p => stop(p, 'Arashiyama Bamboo Grove')),
only(p => stop(p, 'Nishiki Market')),
only(connector),
only(routeBar),
],
},
'place-onto-day': {
guide: guide('place-onto-day'),
start: async p => {
await createSpare(p)
// Day 1 stays open: the "+" of a place row and "To day" exist only while
// a day is selected, and steps 2 and 4 are about those two.
await openTripOnDay(p, 1)
},
steps: [
{
target: p => placeRow(p, SPARE.name),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
act: async p => {
await placeRow(p, SPARE.name).dragTo(dayHeader(p, 2))
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The "+" offers the OPEN day, so it is still there with the place on
// day 2 — and the place stays on a day, which is what the result says.
prepare: async p => { await placeRow(p, SPARE.name).hover() },
target: p => toOpenDay(p, SPARE.name),
},
only(p => p.getByRole('button', { name: 'Add place to this day' }).first()),
only(p => p.getByRole('button', { name: 'Add to the open day' })),
],
cleanup: p => deleteByName(p, SPARE.name),
},
'reorder-stops': {
guide: guide('reorder-stops'),
start: p => openTrip(p),
steps: [
{
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
dropTo: p => stop(p, 'Senso-ji Temple'),
},
{
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
target: p => stop(p, 'teamLab Planets').locator('button:has(svg.lucide-chevron-up)'),
},
{
target: p => stop(p, 'teamLab Planets'),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
},
{
prepare: async p => {
// Only a move that breaks the day's chronology asks, and the arrows
// never do here: the timed stop is the first row of the day. Dropping
// it below the 13:05 departure does, and Cancel leaves the day alone.
const booking = bookingRow(p, /Departure.*LH716/)
const box = await booking.boundingBox()
if (!box) throw new Error('the departure row of day 1 has no box')
await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } })
await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: timeConfirm,
act: async p => {
await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByText('Remove time?')).toHaveCount(0)
await settle(p)
},
},
],
},
'set-stop-times': {
guide: guide('set-stop-times'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await stop(p, 'teamLab Planets').click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: async p => {
await menu(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p).getByText('Start', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
{
prepare: async p => {
// The time pickers are text fields that parse on blur.
const times = modal(p).getByPlaceholder(/^(00:00|2:30 PM)$/)
await times.first().fill('13:00')
await times.first().blur()
await times.nth(1).fill('15:00')
await times.nth(1).blur()
await beat(p, 300)
},
target: p => modal(p).getByText('Start', { exact: true }).locator('xpath=../..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stop(p, 'teamLab Planets').getByText('13:00')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`)
const body = (await res.json()) as { assignments?: { id: number; place?: { name: string }; place_name?: string }[] }
for (const a of body.assignments ?? []) {
if ((a.place?.name ?? a.place_name) === 'teamLab Planets') {
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
}
}
},
},
'remove-from-day': {
guide: guide('remove-from-day'),
start: async p => {
await createSpare(p)
// The "+" puts the place on the OPEN day, so day 1 stays selected.
await openTripOnDay(p, 1)
await placeRow(p, SPARE.name).hover()
await toOpenDay(p, SPARE.name).click()
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
prepare: async p => {
await stop(p, SPARE.name).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Remove from day' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
only(p => placeRow(p, SPARE.name)),
],
cleanup: p => deleteByName(p, SPARE.name),
},
'lock-stop': {
guide: guide('lock-stop'),
start: p => openTrip(p),
steps: [
{
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }),
act: async p => {
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }).click()
await expect(stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' })).toBeVisible()
await settle(p)
},
},
{
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }),
act: async p => {
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }).click()
},
},
],
},
'day-note': {
guide: guide('day-note'),
start: p => openTrip(p),
steps: [
{
// The "+" of the day's head band opens the menu; Add Note is its last entry.
prepare: async p => {
await addToDay(p, 2).click()
await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Add Note' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Add Note' }).click()
await expect(noteBody(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
// The name is typed into the head band and is what the day card shows,
// the only required field; Daily Note is the markdown the toolbar formats.
await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title)
await typeInto(p, noteBody(p), NOTE.body)
await beat(p, 300)
},
target: noteDialog,
},
{
target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'),
act: async p => {
await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(noteDialog(p)).toHaveCount(0)
await expect(noteRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await noteRow(p).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: noteRow,
act: closeMenu,
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[1]}/notes`)
const body = (await res.json()) as { notes?: { id: number; text: string }[] } | { id: number; text: string }[]
const notes = Array.isArray(body) ? body : (body.notes ?? [])
for (const note of notes.filter(n => n.text.startsWith(NOTE.title))) {
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
}
},
},
'day-route': {
guide: guide('day-route'),
start: p => openTripOnDay(p, ROUTED_DAY),
steps: [
{
target: p => p.getByRole('button', { name: 'Route', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).click()
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
{
prepare: async p => {
await connector(p).click()
await expect(menu(p).getByRole('button', { name: 'Use day default' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: closeMenu,
},
only(p => p.getByRole('button', { name: 'Optimize' })),
// The two hand-offs are siblings with no wrapper of their own: their
// parent is the whole route bar, which is already read-day-plan's step 5.
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
],
},
'manage-days': {
guide: guide('manage-days'),
start: p => openTrip(p),
steps: [
only(p => dayHeader(p, 1)),
{
prepare: async p => {
await p.getByRole('button', { name: 'Reorder days' }).click()
await expect(p.getByText('Reorder days').first()).toBeVisible()
await settle(p)
},
// The whole popup: the rows with their arrows and bins, and the two ways
// of adding a day in its footer.
target: reorderPopup,
},
{
// The question before a day goes, answered with Cancel: the picture shows
// what would go with the second day, its places and its landing, and the
// seed keeps all of its days.
prepare: async p => {
await reorderPopup(p).getByRole('button', { name: 'Delete day' }).nth(1).click()
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toBeVisible()
await settle(p)
},
target: reorderPopup,
act: async p => {
await reorderPopup(p).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toHaveCount(0)
await reorderPopup(p).getByRole('button', { name: 'Close', exact: true }).last().click()
await expect(reorderPopup(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
// The steps before leave the first day selected, and a click on a
// selected day folds its panel away: a second one brings it back.
await dayHeader(p, 1).click({ position: { x: 22, y: 20 } })
await settle(p)
if (!(await dayDetails(p).isVisible())) await selectDay(p, 1)
await expect(renameDay(p)).toBeVisible()
await settle(p)
},
// Scoped to the panel: a stop row carries an Edit of its own, so the
// bare name matches two buttons as soon as any day is unfolded. The
// ring goes round the panel's head band, title and pencil together: a
// ring round the pencil alone sits on the day's name beside it.
target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'),
hover: p => renameDay(p).hover(),
act: closeDayDetails,
},
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
],
},
'bookings-in-plan': {
guide: guide('bookings-in-plan'),
start: async p => {
await openTrip(p, { day: 5 })
},
steps: [
// The flight, not the train: Departure and Arrival are what a booking
// gets when it crosses days, and the train starts and ends on one.
only(p => bookingRow(p, /^LH716 FRA → HND Departure/)),
{
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
target: p => stop(p, 'Nishiki Market'),
},
{
prepare: async p => {
await selectDay(p, 1)
await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible()
await settle(p)
},
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
act: closeDayDetails,
},
only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })),
{
prepare: async p => { await stop(p, 'Senso-ji Temple').hover() },
target: p => stop(p, 'Senso-ji Temple').getByRole('button', { name: 'Add booking' }),
},
],
},
'export-plan': {
guide: guide('export-plan'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Export' }),
act: async p => {
await p.getByRole('button', { name: 'Export' }).click()
await expect(modal(p).getByText('Document', { exact: true })).toBeVisible()
await settle(p)
},
},
only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=ancestor::section[1]')),
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')),
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')),
],
cleanup: closeModal,
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureDaysFixtures(request)
})
test('every registered days guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDaysContext.guides].sort())
})
test('hero: trip-days', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
// The plan as it opens: the details panel is trip-day-detail's subject and the
// route is day-route's, so the hero shows neither.
await captureHero(page, tripDaysContext.id, p => openTrip(p))
})
for (const id of tripDaysContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+616
View File
@@ -0,0 +1,616 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Files tab of a trip, keyed by the ids in
* `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so
* `ensureFilesFixtures` puts a small set of drawn ones in before the first
* picture; what a guide uploads, links, stars or throws away it undoes in
* `cleanup`, so the next one starts from that same set.
*/
const guide = (id: string): HelpGuide => {
const g = tripFilesGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The fixture documents each guide acts on, by the name their row carries. */
const STARRED = 'JR-Pass-voucher.pdf'
const LINKED = 'LH716-boarding-pass.pdf'
const DOOMED = 'old-draft-itinerary.pdf'
const PHOTO = 'nishiki-market.jpg'
/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */
const UPLOADED_PDF = 'kyoto-bus-pass.pdf'
const UPLOADED_PHOTO = 'arashiyama.jpg'
const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO]
/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */
const PLACE = 'teamLab Planets'
const FLIGHT = 'LH716 FRA → HND'
const NOTE = 'Seat 34K, boarding 12:20'
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
/**
* The file rows, in the order the list shows them. A row is the `.group` card
* whose last child holds the row's icon actions (`.file-actions`); matching on
* that keeps any other hover group on the trip page out of the count.
*/
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
/** A row's actions are icons; each takes its accessible name from its tooltip. */
const action = (page: Page, name: string, label: string) =>
row(page, name).getByRole('button', { name: label, exact: true })
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
/** The filter row, found through the one tab that is always there. */
const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..')
/** The star tab has no text: its accessible name is the count alone. */
const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first()
/**
* The number on a filter tab. Reading it rather than counting the fixture keeps
* the step honest about the one thing it is really claiming: that the tab shows
* exactly as many rows as its badge promises, and it survives another screen's
* fixture adding a file of its own.
*/
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
/**
* The trash icon at the right end of the bar, named by its tooltip. Trash and
* Empty Trash both contain "Trash", so it needs `exact`.
*/
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
/**
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
* its head band (Assign File, over the file's name) is what labels it.
*/
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
/**
* The lightbox, found by the one header button only it and the document preview
* carry, and they are never open at the same time. Its three direct children
* are the header, the picture and the strip of thumbnails.
*/
const lightbox = (page: Page) =>
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
/**
* The preview for everything that is not a picture: a DialogShell panel with
* the file's name in the head band. For a PDF it holds the browser's embedded
* viewer, the one `<object>` only it renders; the markdown preview
* (`.collab-note-md`) has no step of its own.
*/
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
/** The round × in a dialog's head band, named Close by its tooltip. */
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
/**
* A DialogShell hands the focus back to whatever opened it when it closes,
* and a Tooltip opens on focus as well as on hover. The opener of the assign
* dialog and of document sync is an icon with a tooltip, so without this the
* after-picture shows "Assign" or "Document sync" floating under the pointer
* that has long since moved away.
*/
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
/** The head band's title; the trip's name stands under it as a line of its own. */
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
/**
* A store row in the dialog's sidebar. Its accessible name runs on into the
* subtitle (Files in a folder), so it is matched by its start.
*/
const storeRow = (page: Page, name: string) => modal(page).getByRole('button', { name: new RegExp(`^${name}\\b`) })
/**
* A box of the connection form, by the placeholder the store's field carries.
* The labels are no good as locators here: the hint under Username says
* "email address", so a lookup by the label Address finds two boxes.
*/
const formBox = (page: Page, placeholder: string) => dialog(page).getByPlaceholder(placeholder, { exact: true })
/** The folder picker's row for the folder the fixture put in the store; the Create button is not in a list. */
const folderRow = (page: Page) => dialog(page).locator('li button').filter({ hasText: NEXTCLOUD_FOLDER }).first()
/** The binding card, the one <article> in the dialog once a store is bound. */
const bindingCard = (page: Page) => dialog(page).locator('article').first()
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
interface ApiFile {
id: number
original_name: string
starred?: number
}
async function listFiles(page: Page, trash = false): Promise<ApiFile[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`)
const body = (await res.json()) as { files?: ApiFile[] }
return body.files ?? []
}
/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */
async function removeFiles(page: Page, names: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
}
for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
}
/** Put the note and every link the guide made back to nothing. */
async function unlinkFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page)).find(f => f.original_name === name)
if (!file) return
await page.request.put(`/api/trips/${tripId}/files/${file.id}`, {
data: { place_id: null, reservation_id: null, description: '' },
})
const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`)
const { links = [] } = (await res.json()) as { links?: { id: number }[] }
for (const link of links) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`)
}
}
/** The star route toggles, so it is only called back when the file really is starred. */
async function unstarFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page)).find(f => f.original_name === name)
if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`)
}
/** Bring a file the guide threw away back out of the trash, if it is still in there. */
async function untrashFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page, true)).find(f => f.original_name === name)
if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'files-upload': {
guide: guide('files-upload'),
// Starts on the plan, because the first step is reaching the tab.
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => p.getByRole('button', { name: 'Files', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Files', exact: true }).click()
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
await expect(dropzone(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: dropzone,
// The hidden input takes the files directly: clicking the box would open
// the operating system's picker, which Playwright cannot answer.
act: async p => {
await dropzone(p).locator('input[type="file"]').setInputFiles([
filesPdfFixture('kyoto-bus-pass'),
await filesImageFixture('arashiyama'),
])
await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
// The whole panel: the head band with the file's name, the note and
// the two lists are what the step's text walks through.
target: assignDialog,
act: async p => {
await closeX(assignDialog(p)).click()
await expect(assignDialog(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => row(p, UPLOADED_PHOTO),
},
],
cleanup: p => removeFiles(p, UPLOADED),
},
'files-link': {
guide: guide('files-link'),
start: openFiles,
steps: [
{
target: p => action(p, LINKED, 'Assign'),
act: async p => {
await action(p, LINKED, 'Assign').click()
await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, noteBox(p), NOTE)
},
// The label above the box belongs in the picture, so the step rings the
// block rather than the input.
target: p => noteBox(p).locator('xpath=..'),
// The note is written on blur, and Enter blurs; the row behind the
// dialog picks it up, which is the proof that it was saved.
act: async p => {
await noteBox(p).press('Enter')
await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 })
},
},
{
// Exact: the bookings fixture names a booking after this place, and the
// dialog lists both under the same opening words.
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
act: async p => {
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
},
},
{
target: p => assignDialog(p).getByRole('button', { name: FLIGHT }),
act: async p => {
await assignDialog(p).getByRole('button', { name: FLIGHT }).click()
await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
},
},
{
target: p => closeX(assignDialog(p)),
act: async p => {
await closeX(assignDialog(p)).click()
await expect(assignDialog(p)).toHaveCount(0)
await dropFocus(p)
// A badge carries the name alone now; what it links to is its tooltip.
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => unlinkFile(p, LINKED),
},
'files-star': {
guide: guide('files-star'),
start: openFiles,
steps: [
{
target: p => action(p, STARRED, 'Star'),
act: async p => {
await action(p, STARRED, 'Star').click()
await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// Sorting is the server's (starred first, then newest first), so the
// list is only right once the reload has come back.
prepare: async p => {
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
await settle(p)
},
target: p => rows(p).first(),
},
{
target: starTab,
act: async p => {
const starred = await tabCount(starTab(p))
await starTab(p).click()
await expect(rows(p)).toHaveCount(starred)
await settle(p)
},
},
],
cleanup: p => unstarFile(p, STARRED),
},
'files-filter': {
guide: guide('files-filter'),
start: openFiles,
steps: [
only(tabRow),
{
target: p => p.getByRole('button', { name: /^PDFs \d+$/ }),
act: async p => {
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
await expect(rows(p)).toHaveCount(pdfs)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }),
act: async p => {
await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click()
await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^All \d+$/ }),
act: async p => {
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
await p.getByRole('button', { name: /^All \d+$/ }).click()
await expect(rows(p)).toHaveCount(all)
await settle(p)
},
},
],
},
'files-preview': {
guide: guide('files-preview'),
start: openFiles,
steps: [
{
// Two buttons in the row carry the file's name: the thumbnail and the
// name itself. The reader is told about the name, which comes second.
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
act: async p => {
await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click()
await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
target: p => lightbox(p).locator('> div').last(),
// Which way the picture can move depends on where it sits in the strip,
// and the right chevron is there only while there is a next one.
act: async p => {
const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0
await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft')
await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// The header's button group, not the whole header bar: the bar runs the
// full width of the frame, so ringing it rings mostly empty space.
target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'),
// The group's own centre falls between its buttons, where the picture
// underneath takes the pointer, so hovering it never lands. The pointer
// goes on a button instead; the ring is still round the group.
hover: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).hover(),
act: async p => {
await p.keyboard.press('Escape')
await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// A PDF this time rather than the markdown file: the browser's own
// viewer draws the page inside the card, which is what a reader sees
// for a ticket. The card gets its source only once the signed download
// link is minted (useFileManager.ts: getAuthUrl → setPreviewFileUrl),
// and no DOM event says when the viewer has painted, so the step waits
// for the file's own response and then a moment more, as the satellite
// step of the map guide does for its tiles.
prepare: async p => {
const fetched = p
.waitForResponse(r => r.url().includes('/download?token=') && r.ok(), { timeout: 30_000 })
.catch(() => null)
await row(p, LINKED).getByRole('button', { name: LINKED }).last().click()
await expect(pdfPreview(p)).toBeVisible({ timeout: 30_000 })
await expect(pdfViewer(p)).toHaveAttribute('data', /\/download\?token=.+#view=FitH$/, { timeout: 30_000 })
await fetched
// The fallback link inside the <object> is drawn only where the
// browser has no viewer; hidden, it proves the picture shows a page.
await expect(pdfPreview(p).getByRole('button', { name: 'Download PDF' })).toBeHidden()
await settle(p)
await p.waitForTimeout(1500)
},
target: pdfPreview,
act: async p => {
await closeX(pdfPreview(p)).click()
await expect(pdfPreview(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => action(p, STARRED, 'Download'),
// The button fetches the bytes and clicks a synthetic anchor, so the
// browser reports a real download.
act: async p => {
const download = p.waitForEvent('download', { timeout: 30_000 })
await action(p, STARRED, 'Download').click()
await (await download).delete()
await settle(p)
},
},
],
},
'files-trash': {
guide: guide('files-trash'),
start: openFiles,
steps: [
{
target: p => action(p, DOOMED, 'Delete'),
act: async p => {
await action(p, DOOMED, 'Delete').click()
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: trashTab,
act: async p => {
await trashTab(p).click()
await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 })
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => row(p, DOOMED)),
{
target: p => action(p, DOOMED, 'Restore'),
act: async p => {
await action(p, DOOMED, 'Restore').click()
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// Empty Trash is there because the fixture leaves one document in the
// trash. It asks with the browser's own confirm, which Playwright
// dismisses, so clicking it would do nothing at all; the step shows the
// button and leaves the trash by the trash icon instead.
target: p => p.getByRole('button', { name: 'Empty Trash' }),
act: async p => {
await trashTab(p).click()
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => untrashFile(p, DOOMED),
},
'files-sync': {
guide: guide('files-sync'),
// The store's address and app password come from media.env (external.ts).
// They are read inside the guide rather than at import, so a run without
// the file fails this one guide and not the whole screen.
start: async p => {
const { tripId } = seededTrip()
await resetDocSync(p.request, tripId)
await ensureNextcloudFolder(drawPdf)
await openFiles(p)
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: syncButton,
act: async p => {
await syncButton(p).click()
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
// The stores arrive after a spinner; the heading over them says the list is in.
await expect(modal(p).getByText('Connect a provider', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => storeRow(p, 'Nextcloud'),
act: async p => {
await storeRow(p, 'Nextcloud').click()
// The connection dialog stacks on top, so `modal` is now that one.
await expect(modal(p).getByRole('heading', { name: 'Nextcloud', exact: true })).toBeVisible({ timeout: 20_000 })
await expect(formBox(p, 'https://cloud.example.com')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const store = NEXTCLOUD_CONNECTION()
await typeInto(p, formBox(p, 'https://cloud.example.com'), store.baseUrl)
await typeInto(p, formBox(p, 'username'), store.credentials.login_name)
await typeInto(p, formBox(p, 'app password'), store.credentials.app_password)
await typeInto(p, formBox(p, '/TREK'), store.credentials.base_path)
await beat(p, 300)
},
// The whole form, as the create-user step does: the labels and hints belong in the picture.
target: dialog,
},
{
target: p => dialog(p).getByRole('button', { name: 'Test connection' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Test connection' }).click()
await expect(
dialog(p).getByText(`Reached it, signed in as ${NEXTCLOUD_CONNECTION().credentials.login_name}`),
).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Connect saves the credentials and opens the folder picker on top of
// the dialog; the picture is the picker, with the folder the reader is
// told to click, because that is the screen the step is about.
prepare: async p => {
await dialog(p).getByRole('button', { name: 'Connect', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Where should this trip live in Nextcloud?' })).toBeVisible({ timeout: 30_000 })
await expect(folderRow(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
target: folderRow,
act: async p => {
await folderRow(p).click()
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Binding starts a run of its own, which the button cannot join: a Sync
// now while it is going answers busy and moves nothing, and the card
// only learns of that first run from the ping it sends when it is
// through. So the click is repeated until the card reports In sync,
// which is a dot named by its tooltip once the state is good.
prepare: async p => {
await expect(async () => {
await syncNow(p).click({ timeout: 15_000 })
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
}).toPass({ timeout: 120_000, intervals: [3_000] })
await settle(p)
},
target: bindingCard,
// Closed with the × in the head band, as the step's text says.
act: async p => {
await closeX(dialog(p)).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await dropFocus(p)
// The run's file:created events put the pulled documents at the top of the list.
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await resetDocSync(p.request, tripId)
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
// The run pushed the trip's own documents into the folder; back to its two.
await ensureNextcloudFolder(drawPdf)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureFilesFixtures(request)
// A store on offer puts Document sync beside the trash icon for the trip's
// owner, which is how bullet 6 of the screen text describes the bar; on for
// the whole file, so the hero and all seven guides show the same bar.
await toggleAddon(request, 'nextcloud', true)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: every store ships off, and the other
// screens' pictures must not grow the button. Here rather than in the last
// guide's cleanup, so a failed run puts it back too (as trip-roadtrip does).
await toggleAddon(request, 'nextcloud', false)
})
test('every registered files guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort())
})
test('hero: trip-files', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripFilesContext.id, openFiles)
})
for (const id of tripFilesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+758
View File
@@ -0,0 +1,758 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { pickerLabel } from '../dates'
import { seededTrip, ensureListsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { tripListsContext, tripListsGuides } from '../../src/help/contexts/tripLists'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Lists tab of a trip, keyed by the ids in
* `src/help/contexts/tripLists.ts`. They run on the seeded "Autumn in Japan"
* with the fixtures' packing template and two extra tasks in place, so Apply
* template exists and the to-do sidebar has more than one filled filter; what
* a guide creates it takes away again in `cleanup`, so the next one starts the
* same.
*/
const guide = (id: string): HelpGuide => {
const g = tripListsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** What the guides make and unmake. Nothing here is in the seed. */
const NEW_LIST = 'Toiletries'
const FIRST_ITEM = 'Toothbrush'
const RENAMED_ITEM = 'Toothbrush and paste'
const MEMBER = 'mira'
const SHARED_ITEM = 'Camera'
const BAG_ITEM = 'Rain jacket'
const BAG = 'Cabin bag'
const SAVED_TEMPLATE = 'Autumn in Japan'
const NEW_TASK = 'Pick up the pocket wifi'
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
const TASK_LIST = 'On arrival'
const OTHER_TASK_LIST = 'Before departure'
const EDITED_TASK = 'Activate JR Pass'
const ASSIGNEE = 'jonas'
/** The date picker labels a day cell with the whole date, in the run's English. */
const DUE_DAY = pickerLabel(4)
/** The fixtures' template, and the lists it brings with it. */
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] }
const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30'
const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters']
/** The seed's packing items, in the order it created them. */
const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera']
const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance']
/** The count in the label moves with what is ticked, so it is matched, not spelled. */
const CLEAR_CHECKED = /^Remove \d+ checked$/
// ── Locators ─────────────────────────────────────────────────────────────────
/** A category card: the list's name is a span in the card's header row. */
const cat = (page: Page, name: string): Locator =>
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
/**
* The header row of a card. The ⋯ has to be taken from here: every item row
* carries a ⋯ of its own, so the card as a whole holds several of them.
*/
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
const catMenu = (page: Page, name: string): Locator =>
catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)')
/** The open ⋯ menu is a fixed popover with no class of its own; its last entry names it. */
const catMenuCard = (page: Page): Locator => page.getByRole('button', { name: 'Delete List' }).locator('xpath=..')
const item = (page: Page, name: string): Locator =>
page.locator('.packing-item-row').filter({ hasText: name }).first()
/** The only item of the list the first guide builds; its name changes mid-guide. */
const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first()
/** An item's own ⋯, which holds Move to List, Sharing, Rename and Delete. */
const rowMenu = (page: Page, name: string): Locator => item(page, name).locator('.packing-row-overflow button')
/** The open item menu; Sharing is a whole line in it. */
const rowMenuSharing = (page: Page): Locator =>
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
/** The name dialog that Add list and Save as template open. */
const nameDialog = (page: Page, confirm: string): Locator => page.getByRole('button', { name: confirm, exact: true })
/** The sharing dropdown is portalled to <body>, so the hint's parent is the card itself. */
const shareMenu = (page: Page): Locator =>
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
/** Its click-catcher, the only way back out of the menu once a name is ticked. */
const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last()
/** The bulk-import dialog, a DialogShell whose head band carries its title. */
const importCard = (page: Page): Locator =>
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
/**
* The bag picker of a row and the member picker of a bag both hang off the
* body now, so they are found by their caption rather than inside the row.
*/
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
/** The calendar a date field opens, a portal of its own. */
const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Date picker' })
/** The Export button's menu: print, Markdown and CSV. */
const exportMenu = (page: Page): Locator => page.getByRole('menu')
/** The printable page inside the preview, a srcdoc frame of its own. */
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
/** The Bags card beside the lists. Total weight is a span in its foot band. */
/** The bag sidebar: the innermost box holding the guide's bag and the weight summary below the cards. */
const bagSidebar = (page: Page): Locator =>
page.locator('div')
.filter({ has: page.getByText('Total weight', { exact: true }) })
.filter({ has: page.getByText(BAG, { exact: true }) })
.last()
/** The detail pane on the right; its header reads Task. */
const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]')
/** A task row is a div with role=button whose name starts with the task's own. */
const task = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) }).first()
const sidebarFilter = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) })
const only = (target: (p: Page) => Locator) => ({ target })
// ── Getting there ────────────────────────────────────────────────────────────
/** The tab id in the address is the legacy 'listen'; the tab itself reads Lists. */
async function openLists(page: Page): Promise<void> {
await openTrip(page, { tab: 'listen' })
await expect(page.getByRole('button', { name: /^Packing List/ })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
/**
* The sub-tab is remembered in sessionStorage, which Playwright never restores,
* so every guide opens on Packing List and the to-do ones switch over.
*/
async function openTodos(page: Page): Promise<void> {
await openLists(page)
await page.getByRole('button', { name: /^To-Do/ }).click()
await expect(page.getByRole('button', { name: 'Add new task' })).toBeVisible({ timeout: 15_000 })
await settle(page)
}
// ── Putting the seed back ────────────────────────────────────────────────────
interface PackingRow {
id: number
name: string
category: string | null
}
async function packingItems(page: Page): Promise<PackingRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/packing`)
const body = (await res.json()) as { items?: PackingRow[] }
return body.items ?? []
}
async function deleteItems(page: Page, match: (row: PackingRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
for (const row of (await packingItems(page)).filter(match)) {
await page.request.delete(`/api/trips/${tripId}/packing/${row.id}`)
}
}
/**
* Re-creates the three Documents items the guide really deleted, then writes
* the seed's order back: without it the re-created rows sort last, the
* Documents card slides to the end of the two-column grid, and every later
* guide's picture changes.
*/
async function restoreDocuments(page: Page): Promise<void> {
const { tripId } = seededTrip()
const present = await packingItems(page)
for (const name of DOCUMENTS) {
if (present.some(row => row.name === name)) continue
await page.request.post(`/api/trips/${tripId}/packing`, { data: { name, category: 'Documents', visibility: 'common' } })
}
const after = await packingItems(page)
const orderedIds = SEED_ORDER.map(name => after.find(row => row.name === name)?.id).filter((id): id is number => id !== undefined)
await page.request.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds } })
}
async function deleteTemplate(page: Page, name: string): Promise<void> {
const res = await page.request.get('/api/admin/packing-templates')
const body = (await res.json()) as { templates?: { id: number; name: string }[] }
for (const template of (body.templates ?? []).filter(row => row.name === name)) {
await page.request.delete(`/api/admin/packing-templates/${template.id}`)
}
}
async function deleteTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
const body = (await res.json()) as { items?: { id: number; name: string }[] }
for (const row of (body.items ?? []).filter(x => x.name === name)) {
await page.request.delete(`/api/trips/${tripId}/todo/${row.id}`)
}
}
async function unassignTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
const body = (await res.json()) as { items?: { id: number; name: string }[] }
const row = (body.items ?? []).find(x => x.name === name)
if (row) await page.request.put(`/api/trips/${tripId}/todo/${row.id}`, { data: { assigned_user_id: null } })
}
// ── Scripts ──────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'packing-categories': {
guide: guide('packing-categories'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Add list' }),
act: async p => {
await p.getByRole('button', { name: 'Add list' }).click()
const field = p.getByPlaceholder('List name (e.g. Clothing)')
await typeInto(p, field, NEW_LIST)
await nameDialog(p, 'Add').click()
await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }),
act: async p => {
await cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }).click()
const field = p.getByPlaceholder('Item name...')
await typeInto(p, field, FIRST_ITEM)
// Enter renames the list's '...' row rather than adding a second one,
// and leaves the field open; Escape is how it closes.
await field.press('Enter')
await expect(item(p, FIRST_ITEM)).toBeVisible({ timeout: 20_000 })
await field.press('Escape')
await settle(p)
},
},
{
prepare: async p => { await onlyRow(p).hover() },
// A click on the name renames it; the row has no pencil of its own.
target: p => onlyRow(p).getByRole('button', { name: FIRST_ITEM }),
act: async p => {
await onlyRow(p).getByRole('button', { name: FIRST_ITEM }).click()
// While editing, the name is the row's first input; the quantity and
// the weight come after it.
const field = onlyRow(p).locator('input').first()
await typeInto(p, field, RENAMED_ITEM)
await field.press('Enter')
await expect(item(p, RENAMED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)'),
act: async p => {
const plus = catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)')
await plus.click()
await catHeader(p, NEW_LIST).getByRole('button', { name: MEMBER }).click()
await expect(catHeader(p, NEW_LIST).locator('.assignee-chip')).toHaveCount(1, { timeout: 20_000 })
await plus.click()
await settle(p)
},
},
{
prepare: async p => {
await catMenu(p, NEW_LIST).click()
await expect(p.getByRole('button', { name: 'Delete List' })).toBeVisible()
await beat(p, 300)
},
target: catMenuCard,
// The pointer rests on the three dots that opened it, not on an entry.
// Moved by coordinates: the menu's click-catcher covers the button.
hover: async p => {
const b = await catMenu(p, NEW_LIST).boundingBox()
if (b) await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2)
},
act: async p => {
// The menu sits over a full-viewport catcher; a click in the corner
// closes it without deleting the list the picture was just taken of.
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(p.getByRole('button', { name: 'Delete List' })).toHaveCount(0)
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await deleteItems(p, row => row.category === NEW_LIST)
await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } })
},
},
'check-off-packing': {
guide: guide('check-off-packing'),
start: openLists,
steps: [
{
// The grip is a div, so the tick box is the row's first button.
target: p => item(p, 'Passport').getByRole('button').first(),
act: async p => {
await item(p, 'Passport').getByRole('button').first().click()
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
// The count and the percentage, with the bar beside them: the whole card is
// wider than any frame and would lose both at its edges.
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
{
prepare: async p => {
await catMenu(p, 'Documents').click()
await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Check All', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Check All', exact: true }).click()
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Open', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Open', exact: true }).click()
await expect(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: CLEAR_CHECKED }),
act: async p => {
// A native window.confirm, which Playwright dismisses unless something
// accepts it first. The browser's own dialog cannot be pictured, so
// the step shows the button and the state it leaves behind.
p.once('dialog', d => { void d.accept() })
await p.getByRole('button', { name: CLEAR_CHECKED }).click()
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
cleanup: restoreDocuments,
},
'apply-packing-template': {
guide: guide('apply-packing-template'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Apply template' }),
act: async p => {
await p.getByRole('button', { name: 'Apply template' }).click()
await expect(p.locator('.trek-menu-enter').last()).toBeVisible()
await settle(p)
},
},
{
target: p => p.locator('.trek-menu-enter').last(),
act: async p => {
await p.locator('.trek-menu-enter').last().getByRole('button', { name: new RegExp(`^${TEMPLATE.name}`) }).click()
await expect(cat(p, TEMPLATE.category)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => p.getByRole('button', { name: /^Shared/ })),
{
target: p => p.getByRole('button', { name: 'Save as template' }),
act: async p => {
await p.getByRole('button', { name: 'Save as template' }).click()
const field = p.getByPlaceholder('Template name')
await typeInto(p, field, SAVED_TEMPLATE)
await nameDialog(p, 'Save').click()
await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
await deleteTemplate(p, SAVED_TEMPLATE)
},
},
'import-packing-list': {
guide: guide('import-packing-list'),
start: openLists,
steps: [
{
// An icon at the right end of the bar, named Import by its label and tooltip.
target: p => p.getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Import', exact: true }).click()
await expect(importCard(p)).toBeVisible()
await settle(p)
},
},
{
// The step's text points at the grey sample, which is the textarea's
// placeholder: it is only on screen while the box is empty, so the lines
// are typed after the picture, not before it.
target: importCard,
act: async p => {
await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES)
await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 })
// Out of the box again, or its dark focus ring reads as a second ring
// in the next two pictures.
await importCard(p).locator('textarea').blur()
await settle(p)
},
},
// In the dialog's footer, on the left. Clicking it would open the operating
// system's file chooser, which is not part of the picture; the button is
// shown, not pressed.
only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })),
{
target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }),
act: async p => {
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
},
'export-packing-list': {
guide: guide('export-packing-list'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Export', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Export', exact: true }).click()
await expect(exportMenu(p)).toBeVisible()
await settle(p)
},
},
// The two file entries download at once; the step shows them, it does not press them.
only(exportMenu),
{
target: p => exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }),
act: async p => {
await exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }).click()
await expect(printFrame(p).locator('h1')).toBeVisible({ timeout: 15_000 })
// The page carries the app's own Poppins; the result picture waits for it.
await printFrame(p).locator('body').evaluate(async () => { await document.fonts.ready })
await settle(p)
},
},
// Pressing it opens the operating system's print dialog, which is not part
// of the picture; the button is shown, not pressed. The result picture is the
// preview itself.
only(p => p.getByRole('button', { name: 'Print or save as PDF' })),
],
cleanup: async p => {
await p.getByRole('button', { name: 'Close', exact: true }).first().click()
},
},
'share-packing-item': {
guide: guide('share-packing-item'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: /^My list/ }),
act: async p => {
await p.getByRole('button', { name: /^My list/ }).click()
await expect(p.getByText('No items match this filter')).toBeVisible({ timeout: 20_000 })
await p.getByRole('button', { name: /^Shared/ }).click()
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await item(p, SHARED_ITEM).hover()
await rowMenu(p, SHARED_ITEM).click()
await expect(rowMenuSharing(p)).toBeVisible()
await beat(p, 300)
},
target: rowMenuSharing,
act: async p => {
await rowMenuSharing(p).click()
await expect(shareMenu(p)).toBeVisible()
await settle(p)
},
},
{
target: shareMenu,
act: async p => {
await shareMenu(p).getByRole('button', { name: /^Personal/ }).click()
await expect(item(p, SHARED_ITEM)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^My list/ }),
act: async p => {
await p.getByRole('button', { name: /^My list/ }).click()
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await item(p, SHARED_ITEM).hover()
await rowMenu(p, SHARED_ITEM).click()
await rowMenuSharing(p).click()
await expect(shareMenu(p)).toBeVisible()
await beat(p, 300)
},
target: p => shareMenu(p).getByRole('button', { name: MEMBER }),
act: async p => {
await shareMenu(p).getByRole('button', { name: MEMBER }).click()
// Ticking a name leaves the menu open on purpose; its catcher closes it,
// and the item menu under it has one of its own.
await shareOverlay(p).click({ position: { x: 5, y: 5 } })
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(p.locator('.trek-menu-enter')).toHaveCount(0)
await expect(item(p, SHARED_ITEM).getByLabel('shared with 1')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const row = (await packingItems(p)).find(x => x.name === SHARED_ITEM)
if (row) {
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
}
},
},
'packing-bags': {
guide: guide('packing-bags'),
start: openLists,
steps: [
{
// The weight field's placeholder really is a dash: it is the app's own
// string, not prose, so it is matched exactly as rendered.
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
act: async p => {
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
await typeInto(p, field, '620')
await expect(field).toHaveValue('620', { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => { await item(p, BAG_ITEM).hover() },
// The circle is dashed with a parcel in it while the item is in no bag.
target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'),
act: async p => {
await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click()
await expect(bagPicker(p).getByRole('button', { name: 'Add bag' })).toBeVisible()
await settle(p)
},
},
{
target: p => bagPicker(p).getByRole('button', { name: 'Add bag' }),
act: async p => {
await bagPicker(p).getByRole('button', { name: 'Add bag' }).click()
const field = bagPicker(p).getByPlaceholder('Bag name...')
await typeInto(p, field, BAG)
await field.press('Enter')
await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 })
await expect(bagPicker(p)).toHaveCount(0)
await settle(p)
},
},
only(bagSidebar),
{
target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }),
act: async p => {
await bagSidebar(p).getByRole('button', { name: 'Set limit' }).click()
// Entered in kilograms, stored in grams.
// The sidebar locator needs Set limit, which the open editor replaces: look page-wide.
const field = p.locator('input[aria-label="Weight limit"]')
await typeInto(p, field, '8')
await field.press('Enter')
await expect(p.getByText('/ 8.0 kg').first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The card's member picker is the dashed plus beside the bag's name; the
// column's other plus, + Bags in its head, adds a bag.
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
act: async p => {
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
await bagMemberPicker(p).getByRole('button', { name: MEMBER }).click()
// The picker closes on a click anywhere beside it.
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/packing/bags`)
const body = (await res.json()) as { bags?: { id: number; name: string }[] }
for (const bag of (body.bags ?? []).filter(x => x.name === BAG)) {
// bag_id is ON DELETE SET NULL, so the item is freed with it.
await p.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
}
const row = (await packingItems(p)).find(x => x.name === BAG_ITEM)
if (row) await p.request.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { weight_grams: null } })
},
},
'create-todo': {
guide: guide('create-todo'),
start: openTodos,
steps: [
{
target: p => p.getByRole('button', { name: 'Add new task' }),
act: async p => {
await p.getByRole('button', { name: 'Add new task' }).click()
await expect(modal(p).getByPlaceholder('Task name')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK)
await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE)
// Out of the field, so its dark focus border does not read as a second ring.
await modal(p).getByPlaceholder('Description (optional)').blur()
await settle(p)
},
// The name is typed into the dialog's head band, where Task name stands.
target: p => modal(p).getByPlaceholder('Task name'),
},
{
target: p => modal(p).getByRole('button', { name: 'No list' }),
act: async p => {
await modal(p).getByRole('button', { name: 'No list' }).click()
// The select's list is portalled to <body>, outside the dialog.
await p.getByRole('button', { name: TASK_LIST, exact: true }).click()
await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'P1', exact: true }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'P2', exact: true }).click()
await settle(p)
},
},
{
prepare: async p => {
// Exact: the keyboard button beside it is "Enter date manually", which a
// loose name match also finds.
await modal(p).getByRole('button', { name: 'Date', exact: true }).click()
await expect(datePicker(p)).toBeVisible()
// The calendar opens on the picture day's month, the last of the trip,
// and four days later can already be in the next one.
if (!(await datePicker(p).getByRole('button', { name: DUE_DAY }).isVisible())) {
await datePicker(p).getByRole('button', { name: 'Next month' }).click()
}
await expect(datePicker(p).getByRole('button', { name: DUE_DAY })).toBeVisible()
await beat(p, 300)
},
target: datePicker,
// The pointer on the day about to be picked, not on whatever the middle is.
hover: async p => { await datePicker(p).getByRole('button', { name: DUE_DAY }).hover() },
act: async p => {
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create task' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create task' }).click()
await expect(p.locator('.trek-modal-backdrop')).toHaveCount(0, { timeout: 30_000 })
await expect(task(p, NEW_TASK)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => deleteTodo(p, NEW_TASK),
},
'todo-filters': {
guide: guide('todo-filters'),
start: openTodos,
steps: [
{
target: p => sidebarFilter(p, 'Overdue'),
act: async p => {
await sidebarFilter(p, 'Overdue').click()
await expect(p.getByRole('heading', { name: 'Overdue' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => sidebarFilter(p, OTHER_TASK_LIST),
act: async p => {
await sidebarFilter(p, OTHER_TASK_LIST).click()
await expect(p.getByRole('heading', { name: OTHER_TASK_LIST })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await sidebarFilter(p, 'All').click()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Priority', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Priority', exact: true }).click()
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => task(p, EDITED_TASK),
act: async p => {
await task(p, EDITED_TASK).click()
await expect(todoPane(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await settle(p)
},
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
act: async p => {
await todoPane(p).getByRole('button', { name: 'Save changes' }).click()
// The row names its assignee as an avatar, labelled with the name.
await expect(task(p, EDITED_TASK).getByLabel(ASSIGNEE)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => unassignTodo(p, EDITED_TASK),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureListsFixtures(request)
})
test('every registered lists guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripListsContext.guides].sort())
})
test('hero: trip-lists', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripListsContext.id, openLists)
})
for (const id of tripListsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+809
View File
@@ -0,0 +1,809 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureMapFixtures } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the map in the middle of a trip's plan, keyed by the ids in
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
* toggle has a second route to draw inside the frame; what a guide changes it
* puts back in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripMapGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */
const map = (page: Page) => page.locator('#trek-map')
/** A cluster bubble. Its only text is the number of places folded into it. */
const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom')
/**
* A place pin. Cluster bubbles and the explore results are markers too, so both
* are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin
* carries an `aria-label` (a place marker is a bare divIcon with no name at all,
* which is why a specific place can only be found through its order badge).
*/
const pins = (page: Page) =>
page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
/** A place pin wearing the place's photo rather than its category icon. */
const photoPins = (page: Page) => pins(page).filter({ has: page.locator('img') })
/**
* The pin the markers guide works on after its bubble opened, marked when it
* was chosen: a photo pin the pointer can reach, or any reachable pin when no
* photo has landed.
*/
const markerPin = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[data-help-pin]')
/** The pin of the stop that is number `n` in the open day. */
const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first()
/** An explore result: the one kind of marker with an accessible name. */
const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]')
/** The end of a drawn booking route. */
const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker')
/** The card that follows the pointer over a pin. */
const hoverCard = (page: Page) => page.getByTestId('tooltip')
/**
* A category of the explore pill. The phone portal renders a second copy of the
* whole pill, hidden at this width, so this goes by role rather than by
* `aria-label`, which would match both.
*/
const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true })
/** The pill itself: the nearest block holding the first category and the last. */
const categoryRow = (page: Page) =>
category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]')
/** The route icon on a booking's row, and the one in the toolbar above the days. */
const connection = (page: Page, shown: boolean) =>
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`)
const allConnections = (page: Page, shown: boolean) =>
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`)
const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel')
/** The round Dawarich button under the whole-trip one; its label is its state. */
const trailPill = (page: Page) => page.getByTestId('dawarich-trail-pill')
/**
* The recorded route. Leaflet writes the dash pattern onto the path, and "6 5"
* is this layer's alone: the pending booking routes use "6, 6", the picked
* alternative "2 7", so the dashed days are the one thing on the map with it.
*/
const trail = (page: Page) => page.locator('#trek-map path[stroke-dasharray="6 5"]')
/**
* The GL renderer. There is no `#trek-map` on it: `MapViewGL` mounts a bare
* `div.w-full.h-full` and MapLibre puts its own canvas inside, so the compass
* guide goes by that class. The map itself is reached through
* `window.__trek_map`, which `MapViewGL` publishes on build for exactly this.
*/
const glCanvas = (page: Page) => page.locator('.maplibregl-canvas')
/**
* The compass button and the frosted pill around it. The planner renders the
* pill twice, once in the desktop cluster beside the category row and once for
* phones, `md:hidden` at this width; `getByRole` leaves the hidden copy out.
*/
const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' })
const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
/**
* The booking detail an endpoint opens: a DialogShell portal, so `dialog` finds
* its panel, and its Close is the round X in the head band.
*/
const bookingDetail = (page: Page) => dialog(page)
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
/** A row of the places column. */
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
/**
* The card that opens under the map when a place is selected. By its own test
* id, not by a button in it: the collection button reads Save to Collection or
* Saved depending on whether that place is already in a list, and the seed puts
* the three Kyoto places in one, so a locator on the label matches for some
* pins and for others not.
*/
const inspector = (page: Page) => page.getByTestId('inspector-scroll')
/** The details panel's collapse: it keeps the day selected, where its close would drop it. */
const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)')
/** A day card's header in the days column, the drop target of a dragged pin. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* Fold the day details away and take the focus off the fold button. The
* button keeps the focus after the click, and TREK's tooltip shows on focus,
* so its Expand would otherwise stand at the foot of every later picture.
*/
async function foldDayDetails(page: Page): Promise<void> {
await collapseDayDetails(page).last().click()
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
/**
* The part of the map between the two floating columns, in page pixels. A
* pin under a column can still be hovered at its edge, but a picture of it is
* a picture of the column.
*/
async function openCorridor(page: Page): Promise<{ left: number; right: number }> {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box')
return { left: box.x + 420, right: box.x + box.width - 420 }
}
/** Like `reachable`, but the pin also has to stand in the open part of the map when one does. */
async function reachableInView(page: Page, locator: Locator, fromEnd = false): Promise<number> {
const { left, right } = await openCorridor(page)
const total = await locator.count()
for (let i = 0; i < total; i++) {
const index = fromEnd ? total - 1 - i : i
const b = await locator.nth(index).boundingBox()
if (!b || b.x < left || b.x + b.width > right || b.y < 260) continue
try {
await locator.nth(index).hover({ trial: true, timeout: 1500 })
return index
} catch {
// Covered by another marker.
}
}
return reachable(locator, fromEnd)
}
/**
* A spot on the map with nothing on it: right of the days column, left of the
* explore pill, and clear of everything the plan floats over the lower half of
* the map. Map-relative, so it is passed as a click `position`.
*/
const EMPTY = { x: 470, y: 80 }
/**
* The first marker of a set the pointer can actually reach. Leaflet stacks
* markers that overlap and the two columns float over the edges of the map, so
* `.first()` is regularly one that nothing can hover, let alone click. Explore
* results are searched from the end because Leaflet draws the southernmost
* marker on top.
*/
async function reachable(locator: Locator, fromEnd = false): Promise<number> {
const total = await locator.count()
for (let i = 0; i < total; i++) {
const index = fromEnd ? total - 1 - i : i
try {
await locator.nth(index).hover({ trial: true, timeout: 1500 })
return index
} catch {
// Covered by another marker or by one of the floating columns.
}
}
throw new Error(`no reachable marker among ${total}`)
}
/**
* Drag the map the way a reader would, so "Search this area" appears.
*
* Not the arrow keys: Leaflet only listens for them while its container has
* focus, and focusing it from here did not take, so the map never moved and the
* button that only exists once it has never appeared. The drag starts from a
* point checked to be empty, because a press on a marker drags the place onto a
* day instead of moving the map.
*/
async function panMap(page: Page, by: { x: number; y: number } = { x: -176, y: -72 }): Promise<void> {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box to drag')
const candidates = [0.3, 0.7, 0.5].flatMap(fx => [0.75, 0.25].map(fy => ({
x: Math.round(box.x + box.width * fx),
y: Math.round(box.y + box.height * fy),
})))
let from: { x: number; y: number } | undefined
for (const point of candidates) {
const clear = await page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y)
return !!el && !el.closest('.leaflet-marker-icon, button, [role="button"], .leaflet-control')
}, point)
if (clear) { from = point; break }
}
if (!from) throw new Error('found nowhere on the map to take hold of')
await page.mouse.move(from.x, from.y)
await page.mouse.down()
// In steps, so Leaflet reads a drag rather than a click.
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + (by.x * i) / 8, from.y + (by.y * i) / 8, { steps: 2 })
await page.mouse.up()
await settle(page)
}
/**
* Open the trip framed on day 1's two Tokyo stops.
*
* The frame is what the three guides starting here have in common: one
* right-clicks a fixed point on the map and needs a street under it, one asks
* the map what it can see for cafes, one puts imagery under the pins. The
* whole-trip fit spans Japan, where that fixed point is the Sea of Japan as
* often as it is a street and the address look-up comes back empty, and zooming
* into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by
* chance. Day 1 is the one frame the seed guarantees is over a city.
*
* The details panel is folded rather than closed: closing it deselects the day
* (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to
* the whole trip.
*/
async function openOnTokyo(page: Page): Promise<void> {
await openTripOnDay(page, 1)
await foldDayDetails(page)
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
await settle(page)
}
/**
* Which renderer draws the planner and the journals. On the account, not on
* the trip: `MapViewAuto` reads `settings.map_provider`, and `loadSettings`
* runs on every page load, so a `page.goto` after this sees the new map.
*/
async function setMapProvider(page: Page, provider: 'leaflet' | 'maplibre-gl'): Promise<void> {
const res = await page.request.post('/api/settings/bulk', { data: { settings: { map_provider: provider } } })
if (!res.ok()) throw new Error(`could not set map_provider=${provider}: ${res.status()} ${await res.text()}`)
}
/** The slice of the GL map the compass guide reads and moves, on `window.__trek_map`. */
interface TrekGlMap {
getBearing: () => number
getPitch: () => number
loaded: () => boolean
areTilesLoaded: () => boolean
isMoving: () => boolean
easeTo: (o: { bearing: number; pitch: number; duration: number }) => unknown
}
/** Wait until the GL map has its style, its tiles and a still camera. */
async function glMapIdle(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && m.loaded() && m.areTilesLoaded() && !m.isMoving()
},
undefined,
{ timeout: 60_000 },
)
}
/**
* Wait until the raster basemap has actually painted.
*
* `toBeVisible()` on a tile is not enough: Leaflet puts the <img> in the DOM
* with its position and its size before the byte arrives, so a picture taken on
* that signal catches the grey underneath. A tile counts only once the browser
* has decoded it, and a screenful needs several of them.
*/
async function rasterTilesPainted(page: Page, atLeast = 6): Promise<void> {
await page.waitForFunction(
min => {
const tiles = Array.from(document.querySelectorAll('#trek-map img.leaflet-tile'))
const painted = tiles.filter(t => {
const img = t as HTMLImageElement
return img.complete && img.naturalWidth > 0
})
return painted.length >= min
},
atLeast,
{ timeout: 60_000 },
)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => names.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/**
* The markers the running guide works on. Which one is reachable is decided
* once, by `reachable`, and the steps that follow reuse the answer; `pin` holds
* a place pin, an explore result or a route endpoint, whichever the guide is
* about. `found` is the name of the explore result a guide took into the trip,
* so `cleanup` can take exactly that one out again.
*/
let bubble = 0
let pin = 0
let found = ''
const NEW_PLACE = 'Lunch stop'
const SCRIPTS: Record<string, GuideScript> = {
'map-lock': {
guide: guide('map-lock'),
start: openOnTokyo,
steps: [
{
target: p => p.getByTestId('map-lock-pill'),
act: async p => {
await p.getByTestId('map-lock-pill').click()
await expect(p.getByRole('button', { name: 'Let the map follow the selection' })).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
const unlock = p.getByRole('button', { name: 'Let the map follow the selection' })
if (await unlock.count()) await unlock.click()
},
},
'map-markers': {
guide: guide('map-markers'),
start: async p => {
await openTrip(p)
await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
bubble = await reachable(clusters(p))
},
steps: [
{
target: p => clusters(p).nth(bubble),
act: async p => {
await clusters(p).nth(bubble).click()
await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2)
// The pin's own photo is a thumb cut after the page has loaded, and
// until it lands the pin is a bare icon beside a card that already
// shows the photo. Bounded: a place with no photo anywhere stays an icon.
await expect.poll(() => photoPins(p).count(), { timeout: 20_000 }).toBeGreaterThan(0).catch(() => {})
await settle(p)
// Marked rather than counted: more thumbs keep landing, and an index
// among the photo pins would point at another pin a moment later.
const photoPin = await reachable(photoPins(p)).catch(() => -1)
const chosen = photoPin >= 0 ? photoPins(p).nth(photoPin) : pins(p).nth(await reachable(pins(p)))
await chosen.evaluate(el => el.setAttribute('data-help-pin', ''))
},
},
{
// captureGuide hovers the target itself; this only makes sure the card
// is up before the shot rather than a frame after it.
prepare: async p => {
await markerPin(p).hover()
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
},
target: markerPin,
},
{
// The picture worth having is the panel the click opens, not the pin
// again, so the click is the preparation and the panel is the target.
prepare: async p => {
await markerPin(p).click()
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: p => inspector(p).locator('xpath=..'),
act: async p => {
await map(p).click({ position: EMPTY })
await expect(inspector(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
await selectDay(p, 1)
// The panel IS the selection, so it is folded away rather than closed:
// closing it would take the numbers off the pins again.
await foldDayDetails(p)
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => badged(p, 1),
},
{
// Picture only, like the other drags in this family: HTML5 drag is what
// the map hands the day plan, and a synthesised one does not start it.
// Day 1's second stop onto day 3, the first empty day: a drop there
// plainly plans something new, where one onto a day that already holds
// the place would read as a move.
prepare: async p => {
await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => badged(p, 2),
dropTo: p => dayHeader(p, 3).locator('xpath=..'),
},
],
},
'map-nearby-places': {
guide: guide('map-nearby-places'),
// A search covers what is on screen, and day 1's fit is the two Tokyo stops
// with the city between them: enough of a district for the sixty hits to
// spread over the frame instead of piling up in the middle of it.
start: openOnTokyo,
steps: [
{ target: categoryRow },
{
target: p => category(p, 'Cafés'),
act: async p => {
await category(p, 'Cafés').click()
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
await settle(p)
},
},
{
prepare: async p => {
// Towards the right. The search covers the whole map, the part under
// the two columns included, and on day 1's frame most of the first
// answer lay under the days column; dragged this way, the west of the
// view is open map when it is searched again, and the new pins are
// where a reader can see them.
await panMap(p, { x: 440, y: 96 })
await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 })
},
target: p => p.getByRole('button', { name: 'Search this area' }),
act: async p => {
await p.getByRole('button', { name: 'Search this area' }).click()
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
await settle(p)
},
},
{
prepare: async p => {
pin = await reachableInView(p, poiPins(p), true)
},
target: p => poiPins(p).nth(pin),
act: async p => {
await poiPins(p).nth(pin).click()
const name = modal(p).getByPlaceholder('e.g. Eiffel Tower')
await expect(name).not.toHaveValue('', { timeout: 20_000 })
// Which places OpenStreetMap answers with is never the same twice, so
// the name is read off the form rather than assumed, and `cleanup`
// deletes exactly what was created.
found = await name.inputValue()
await settle(p)
},
},
// The whole dialog: the name is typed in its head band, outside the form.
{ target: p => dialog(p) },
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, found)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, found),
},
'map-add-place': {
guide: guide('map-add-place'),
start: openOnTokyo,
steps: [
{
target: map,
// The map fills the frame, so the ring would sit nowhere in particular:
// park the pointer on the spot the right-click is about to land on.
hover: async p => {
const box = await map(p).boundingBox()
if (!box) throw new Error('the map has no box')
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
},
act: async p => {
await map(p).click({ button: 'right', position: EMPTY })
await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
// The address is a look-up of the coordinates and lands after the form.
await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('')
await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 })
await settle(p)
},
target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'),
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
await settle(p)
},
target: p => dialog(p),
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, NEW_PLACE),
},
'map-satellite': {
guide: guide('map-satellite'),
start: async p => {
await openOnTokyo(p)
pin = await reachable(pins(p))
},
steps: [
{
target: p => p.getByRole('button', { name: 'Switch to satellite view' }),
act: async p => {
await p.getByRole('button', { name: 'Switch to satellite view' }).click()
await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible()
// The imagery is raster tiles from a third party, and the switch is
// done long before they are on screen.
await rasterTilesPainted(p)
await settle(p)
await p.waitForTimeout(1500)
},
},
{ prepare: p => rasterTilesPainted(p), target: p => pins(p).nth(pin) },
{
prepare: p => rasterTilesPainted(p),
target: p => p.getByRole('button', { name: 'Switch to map view' }),
act: async p => {
await p.getByRole('button', { name: 'Switch to map view' }).click()
await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible()
await settle(p)
},
},
],
// The layer is kept on the account, not on the trip: left on satellite it
// would be the basemap of every picture taken after this guide.
cleanup: async p => {
await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } })
},
},
'map-whole-trip': {
guide: guide('map-whole-trip'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByTestId('trip-overview-pill'),
act: async p => {
await p.getByTestId('trip-overview-pill').click()
// One router request per leg, paced, so the card takes its time.
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
// The total carries an … until every leg has answered; a picture taken
// before that shows a partial sum.
prepare: async p => {
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
await settle(p)
},
target: overviewPanel,
},
{
target: p => overviewPanel(p).getByRole('button').first(),
act: async p => {
await overviewPanel(p).getByRole('button').first().click()
await settle(p)
// The text's second half: the wheel over Tokyo. With the whole trip
// shown, the camera stays on all of it after the pick (the fit that
// follows takes in every day's line), and there a day of ten
// kilometres is a few pixels under a bubble; four notches bring days
// 1 and 2 out side by side. Tokyo is the easternmost bubble of the seed.
const boxes = await clusters(p).evaluateAll(els => els.map(el => {
const r = el.getBoundingClientRect()
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
}))
const tokyo = boxes.sort((a, b) => b.x - a.x)[0]
if (!tokyo) throw new Error('no bubble on the map to zoom in on')
await p.mouse.move(tokyo.x, tokyo.y)
for (let i = 0; i < 4; i++) {
await p.mouse.wheel(0, -100)
await p.waitForTimeout(450)
}
await settle(p)
},
},
// No act: the guide ends with the whole trip on the map, which is what
// the result picture is of. `cleanup` presses the button.
{ target: p => p.getByRole('button', { name: 'Hide whole trip' }) },
],
cleanup: async p => {
const off = p.getByRole('button', { name: 'Hide whole trip' })
if (await off.isVisible().catch(() => false)) await off.click()
},
},
'map-booking-routes': {
guide: guide('map-booking-routes'),
start: p => openTrip(p),
steps: [
{ target: p => connection(p, false).first() },
{
prepare: async p => {
await connection(p, false).first().click()
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1)
await settle(p)
pin = await reachable(endpoints(p))
},
target: p => endpoints(p).nth(pin),
act: async p => {
await endpoints(p).nth(pin).click()
await expect(bookingDetailClose(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole detail, head band to footer: the text walks through all of it.
target: bookingDetail,
act: async p => {
await bookingDetailClose(p).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => allConnections(p, false),
act: async p => {
await allConnections(p, false).click()
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2)
await settle(p)
},
},
{
target: p => allConnections(p, true),
act: async p => {
await allConnections(p, true).click()
await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
// The per-trip choice lives in this browser, not on the server, so it would
// otherwise carry into every guide that runs after it in the same context.
cleanup: async p => {
const { tripId } = seededTrip()
await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId)
},
},
'map-dawarich-trail': {
guide: guide('map-dawarich-trail'),
// Day 1 in Tokyo: at the whole-trip zoom the recording is one dashed line
// along the Shinkansen and the walks through the cities vanish under the
// pins, so the map is framed on a day before the button is pressed;
// nothing fits the map to the trail.
start: openOnTokyo,
steps: [
{
target: trailPill,
act: async p => {
await trailPill(p).click()
// One read of the trip's dates from the Dawarich, thinned to 600
// points a day. The label is the reliable signal: anything but Hide
// (nothing recorded, unreachable, offline) is not a picture worth
// taking, so the wait fails on it rather than shooting an empty map.
await expect(trailPill(p)).toHaveAttribute('aria-label', 'Hide recorded route', { timeout: 120_000 })
await expect(trail(p).first()).toBeAttached({ timeout: 30_000 })
await settle(p)
await p.waitForTimeout(600)
},
},
{
// The whole map: the dashed day under the planned route, with the
// pointer parked on an empty spot so no pin's card is in the picture.
target: map,
hover: async p => {
const box = await map(p).boundingBox()
if (!box) throw new Error('the map has no box')
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
},
},
{
target: p => p.getByTestId('trip-overview-pill'),
act: async p => {
await p.getByTestId('trip-overview-pill').click()
// One router request per leg, paced; the total carries an … until
// every leg has answered, and the result picture waits for that.
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
await settle(p)
},
},
],
cleanup: async p => {
const off = p.getByRole('button', { name: 'Hide whole trip' })
if (await off.isVisible().catch(() => false)) await off.click()
if ((await trailPill(p).getAttribute('aria-pressed').catch(() => null)) === 'true') await trailPill(p).click()
// Per trip and per browser session; the test's context ends here, but
// be explicit rather than trust that.
const { tripId } = seededTrip()
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
},
},
'map-compass': {
guide: guide('map-compass'),
// The compass only exists on the GL renderers, so the guide switches the
// account to MapLibre GL (no token, OpenFreeMap tiles) before opening the
// trip, and `cleanup` puts Leaflet back: every other map guide goes by
// `#trek-map`, which the GL map does not have.
start: async p => {
await setMapProvider(p, 'maplibre-gl')
await openTrip(p)
// MapViewAuto shows Leaflet while the GL chunk loads, then swaps; WebGL
// runs on SwiftShader headless at 1920×1080×2, so the style, the vector
// tiles from tiles.openfreemap.org and the day-1 framing take their time.
await expect(glCanvas(p)).toBeVisible({ timeout: 60_000 })
await expect(resetNorth(p)).toBeVisible({ timeout: 30_000 })
await glMapIdle(p)
await settle(p)
// `settle` counts `<img>` loads; a WebGL canvas has none, so give the
// tiles a moment to paint or the picture catches the blank canvas.
await p.waitForTimeout(2000)
},
steps: [
{
// The turn itself is the map's own camera move rather than a synthetic
// right-button drag: a drag has to start on bare canvas, and the pins,
// the two floating columns and the explore pill all sit over it. 35°
// is enough for the streets to lie visibly askew and the arrow to lean.
prepare: async p => {
await p.evaluate(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
if (!m) throw new Error('no GL map on the page')
m.easeTo({ bearing: 35, pitch: 0, duration: 0 })
})
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && Math.abs(m.getBearing() - 35) < 0.5 && !m.isMoving()
})
await settle(p)
},
target: compassPill,
},
{
target: compassPill,
act: async p => {
await resetNorth(p).click()
// `easeTo` takes 300 ms; the compass is only upright once it has landed.
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && Math.abs(m.getBearing()) < 0.5 && Math.abs(m.getPitch()) < 0.5 && !m.isMoving()
})
await settle(p)
},
},
],
// The renderer is kept on the account, not on the trip: left on MapLibre it
// would be the map of every picture taken after this guide, and every
// `#trek-map` locator in the other guides would find nothing.
cleanup: p => setMapProvider(p, 'leaflet'),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureMapFixtures(request)
// The recorded route: the addon on, the signed-in user connected to the
// Dawarich from media.env, the synthetic recording of the trip uploaded.
await ensureDawarichConnection(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: while the addon is on, every trip map
// carries the round Dawarich button in its bottom-right corner. This file's
// pictures show it on purpose; the other trip screens' do not, so it goes
// off again here, whatever happened in between. The renderer likewise: the
// compass guide puts Leaflet back in its cleanup, but a guide that fails
// never reaches its cleanup, and every `#trek-map` locator in the files that
// run after this one would find a GL canvas instead.
await disconnectDawarich(request)
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
})
test('every registered map guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort())
})
test('hero: trip-map', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripMapContext.id, p => openTrip(p))
})
for (const id of tripMapContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+610
View File
@@ -0,0 +1,610 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, coverFixture, ensureTrack, ensureDaysFixtures, ensurePlaceFixtures, PLACE_WEBSITE, RATED_PLACE } from './fixtures'
import { openTrip, selectDay, modal, dialog } from './trip-shared'
import { long } from '../dates'
import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the place card that floats over the map, keyed by the ids in
* `src/help/contexts/tripPlace.ts`. They run on the seeded "Autumn in Japan"
* with the Kyoto walk imported, the day fixtures in place and this screen's own
* fixtures on top; what a guide changes it changes back in `cleanup`, so the
* next one starts from the same card.
*/
const guide = (id: string): HelpGuide => {
const g = tripPlaceGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** An unplanned waypoint of the imported walk: the place that is on no day yet. */
const SPARE = 'Ginkaku-ji'
/** The walk itself: the only place of the trip that carries a path. */
const TRACK = "Philosopher's Path"
/** What the GPX import hands the trip's first track: TRACK_COLORS[0]. */
const TRACK_IMPORT_COLOR = '#1d4ed8'
/** The colour the guide picks instead: TRACK_COLORS[1]. */
const TRACK_PICKED_COLOR = '#ea580c'
/** The booking `ensureDaysFixtures` pins to the market stop on day 6. */
const BOOKING = 'Lunch at Nishiki'
/** The only list the seed creates. */
const LIST = 'Kyoto shortlist'
/** The list's row in the picker: its name, then the status chip once the place is in it. */
const LIST_ROW = new RegExp(`^${LIST}(?: (?:Idea|Planned|Visited))?$`)
const UPLOADED = 'cover-fixture.jpg'
/** The card itself: the scroll body's parent holds head, body and footer. */
const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..')
const head = (page: Page) => card(page).locator('> header')
const footer = (page: Page) => card(page).locator('> footer')
/** A block of the card by its heading: every block is a <section> that opens with it. */
const section = (page: Page, heading: string) =>
card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
/** A row of the places column (the rows are options of a list). */
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */
const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first()
/** PlaceRating's root: the stars, the average and the voter faces on one line. */
const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..')
/** ParticipantsBox: the block under its heading. */
const participants = (page: Page) => section(page, 'Participants')
/** A member's chip, or, once the chip is gone, that member's line in the add list. */
const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) })
const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true })
/** "Files" while the place has none, "<n> files" once it has. */
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ })
/** The booking strip opens the booking; it is a button named after it. */
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
/** The booking the strip opens, named by its title. Its form is one Edit further. */
const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING })
/** The strip's lower row: Date, Time and Booking Code beside each other. */
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
// Exact: the picker's own button is named "Track color: #EA580C" once the colour is set.
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR, exact: true })
/** The seed's day 1: the trip starts nine days before the picture day (`start_date: day(-9)`). */
const DAY_ONE = -9
/** The weekday the English UI prints for a seeded day, `Saturday` on a run where day 1 is one. */
const weekday = (offset: number) => long(offset).split(',')[0]
/**
* The Opening Hours row: the one button of the card with a clock in it. Found
* by the icon rather than the label, because the label is the open day's line
* ("Saturday: 06:00-17:00") and the day moves with the run.
*/
const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)')
/** The hours block: the row and, once unfolded, the week in its place. */
const hoursBox = (page: Page) => section(page, 'Opening Hours')
/** The card's own X, the one in its head; the day panel over the map has another. */
async function closeCard(page: Page): Promise<void> {
await head(page).locator('button:has(svg.lucide-x)').click()
await expect(card(page)).toHaveCount(0)
}
/**
* Put the provider's answer for a place into the card's own cache before the
* card asks for it. The card reads `sessionStorage` under this key first
* (PlaceInspector's usePlaceDetails) and only then asks the server, with an
* eight-second limit that Overpass exceeds as often as not; asked here over
* the API there is no such limit, and a thin answer is asked again.
*/
async function primeDetails(page: Page, placeName: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { name: string; osm_id?: string | null }[] } | { name: string; osm_id?: string | null }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
const osmId = places.find(p => p.name === placeName)?.osm_id
if (!osmId) throw new Error(`${placeName} carries no OpenStreetMap id; ensurePlaceFixtures did not run`)
type Details = { opening_hours?: string[] | null } | null
const hasWeek = (d: Details): boolean => Array.isArray(d?.opening_hours) && d.opening_hours.length === 7
let details: Details = null
for (let attempt = 1; attempt <= 3 && !hasWeek(details); attempt++) {
const answer = await page.request.get(`/api/maps/details/${encodeURIComponent(osmId)}?lang=en`, { timeout: 45_000 })
details = answer.ok() ? ((await answer.json()) as { place: Details }).place : null
}
if (!hasWeek(details)) throw new Error(`no opening hours for ${placeName} (${osmId}) from the details route`)
await page.evaluate(([key, value]) => sessionStorage.setItem(key, value), [`gdetails_${osmId}_en`, JSON.stringify(details)])
}
/**
* Open a place that carries an OpenStreetMap id and wait until the provider's
* answer is on the card.
*
* The hours, the ring and the phone number arrive one request after the card:
* the server asks Overpass and Nominatim for them, caches nothing on that path,
* and the card swallows a lookup that times out. `settle` waits five seconds
* for the network at most, which a slow mirror exceeds, and the card would then
* be pictured without the feature the text is about. So this waits for the
* hours row itself, and when it has not come, closes the card and opens the
* place again, which fires the lookup afresh (closing clears the id the hook
* watches). A third miss fails the run. With `day` the row's line is checked
* against that day's weekday, which is what the step text promises; with
* `phone` the tel link has to be there too.
*/
async function openWithHours(
page: Page,
open: (p: Page) => Promise<void>,
opts: { day?: number; phone?: RegExp; place?: string } = {},
): Promise<void> {
await primeDetails(page, opts.place ?? 'Senso-ji Temple')
for (let attempt = 1; ; attempt++) {
await open(page)
const shown = await hoursRow(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false)
if (shown) break
if (attempt === 3) throw new Error(`no opening hours on the card after ${attempt} openings`)
await closeCard(page)
}
if (opts.day !== undefined) await expect(hoursRow(page)).toHaveText(new RegExp(`^${weekday(opts.day)}: `))
if (opts.phone) await expect(card(page).getByRole('link', { name: opts.phone })).toBeVisible()
await settle(page)
}
async function openPlace(page: Page, name: string): Promise<void> {
await row(page, name).click()
await expect(card(page)).toBeVisible()
await settle(page)
}
/** Opening the card from a stop is what gives it the stop's participants and booking. */
async function openStop(page: Page, name: string): Promise<void> {
await stop(page, name).click()
await expect(card(page)).toBeVisible()
await settle(page)
}
// ── The seed over the API, for the cleanups ──────────────────────────────────
type ApiPlace = { id: number; name: string; lat?: number | null; lng?: number | null }
type ApiAssignment = { id: number; place_id: number }
async function tripPlaces(page: Page): Promise<ApiPlace[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: ApiPlace[] } | ApiPlace[]
return Array.isArray(body) ? body : (body.places ?? [])
}
const findPlace = async (page: Page, name: string): Promise<ApiPlace | undefined> =>
(await tripPlaces(page)).find(p => p.name === name)
async function patchPlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
const { tripId } = seededTrip()
const place = await findPlace(page, name)
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
}
async function assignmentsOf(page: Page, dayIndex: number): Promise<ApiAssignment[]> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: ApiAssignment[] }
return body.assignments ?? []
}
async function assignmentFor(page: Page, dayIndex: number, name: string): Promise<ApiAssignment | undefined> {
const place = await findPlace(page, name)
if (!place) return undefined
return (await assignmentsOf(page, dayIndex)).find(a => a.place_id === place.id)
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
/**
* Take a place out of every collection it sits in.
*
* Run before the guide as well as after it: the Collections screen's own guides
* run earlier in the suite and leave places saved, and a place that is already
* in a list offers Saved where this guide's first step looks for Save to
* Collection.
*/
async function unsaveEverywhere(page: Page, name: string): Promise<void> {
const place = await findPlace(page, name)
if (!place) return
const query = new URLSearchParams({ name: place.name, lat: String(place.lat ?? ''), lng: String(place.lng ?? '') })
const res = await page.request.get(`/api/addons/collections/membership?${query.toString()}`)
if (!res.ok()) return
const body = (await res.json()) as { lists?: { place_id: number }[] }
for (const entry of body.lists ?? []) {
await page.request.delete(`/api/addons/collections/places/${entry.place_id}`)
}
}
const SCRIPTS: Record<string, GuideScript> = {
'read-place': {
guide: guide('read-place'),
// Day 1 explicitly: closing a day's detail panel clears the open day, and
// the card is a different card without one.
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
},
steps: [
{
target: p => stop(p, 'Senso-ji Temple'),
act: p => openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ }),
},
only(head),
only(ratingRow),
only(p => card(p).locator('.collab-note-md').first()),
only(participants),
{
// Unfolded before the picture: the step is about the week, and a ring
// around the collapsed row would show the reader one line of it.
prepare: async p => {
await hoursRow(p).click()
await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible()
await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible()
await settle(p)
},
target: hoursBox,
},
only(footer),
],
},
'rate-place': {
guide: guide('rate-place'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, RATED_PLACE)
},
steps: [
only(ratingRow),
{
target: p => card(p).getByRole('radio', { name: '4', exact: true }),
act: async p => {
await card(p).getByRole('radio', { name: '4', exact: true }).click()
await expect(card(p).getByRole('radio', { name: '4', exact: true }))
.toHaveAttribute('aria-checked', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
// captureGuide hovers the target before it shoots, which is the gesture
// the step describes; the tooltip is a portal, so it is asserted here.
target: ratingRow,
act: async p => { await expect(p.getByRole('tooltip')).toBeVisible() },
},
only(p => row(p, RATED_PLACE)),
],
cleanup: async p => {
const { tripId } = seededTrip()
const place = await findPlace(p, RATED_PLACE)
// Only the admin's vote: the two members' votes are the fixture's.
if (place) await p.request.delete(`/api/trips/${tripId}/places/${place.id}/rating`)
},
},
'place-image': {
guide: guide('place-image'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => row(p, 'Arashiyama Bamboo Grove'),
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
},
{
target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }),
act: async p => {
await card(p).locator('input[accept*="image/jpeg"]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(head),
only(p => row(p, 'Arashiyama Bamboo Grove')),
],
cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }),
},
'place-day-assign': {
guide: guide('place-day-assign'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => dayHeader(p, 6),
act: p => selectDay(p, 6),
},
{
target: p => row(p, SPARE),
act: async p => {
await openPlace(p, SPARE)
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible()
},
},
{
target: p => footer(p).getByRole('button', { name: 'Add to Day' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Add to Day' }).click()
await expect(footer(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
// The gesture only: dragging it here would leave the day reordered for the
// last step's picture, and the arrow already says what to do.
{ target: p => stop(p, SPARE), dropTo: p => stop(p, 'Arashiyama Bamboo Grove') },
{
target: p => footer(p).getByRole('button', { name: 'Remove from Day' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Remove from Day' }).click()
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const assignment = await assignmentFor(p, 5, SPARE)
if (assignment) await p.request.delete(`/api/trips/${tripId}/days/${dayIds[5]}/assignments/${assignment.id}`)
},
},
'place-participants': {
guide: guide('place-participants'),
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
},
steps: [
{
target: p => stop(p, 'teamLab Planets'),
act: async p => {
await openStop(p, 'teamLab Planets')
await expect(participants(p)).toBeVisible()
},
},
{
target: p => member(p, 'jonas'),
act: async p => {
await member(p, 'jonas').click()
await expect(member(p, 'jonas')).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await addMember(p).click()
await expect(member(p, 'jonas')).toBeVisible()
await beat(p, 300)
},
target: participants,
},
{
target: p => member(p, 'jonas'),
act: async p => {
await member(p, 'jonas').click()
await expect(addMember(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const assignment = await assignmentFor(p, 0, 'teamLab Planets')
// An empty list is what "everybody is going" is stored as.
if (assignment) {
await p.request.put(`/api/trips/${tripId}/assignments/${assignment.id}/participants`, { data: { user_ids: [] } })
}
},
},
'place-booking': {
guide: guide('place-booking'),
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 6)
},
steps: [
{
target: p => stop(p, 'Nishiki Market'),
act: async p => {
await openStop(p, 'Nishiki Market')
await expect(card(p).getByText(BOOKING)).toBeVisible()
},
},
// The fields rather than the whole strip: step 3 rings the strip, and two
// steps pointing at one element give the reader the same picture twice.
only(bookingFields),
{
target: bookingStrip,
act: async p => {
await bookingStrip(p).click()
await expect(bookingDetail(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await bookingDetail(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
await settle(p)
},
target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'),
act: closeModal,
},
{
// The + is rendered only while its own stop is hovered, so the row is
// put in view and hovered before the target is looked up, and hovered
// again for the picture instead of the button itself.
prepare: async p => {
await stop(p, 'Nishiki Market').scrollIntoViewIfNeeded()
await stop(p, 'Nishiki Market').hover()
await expect(stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' })).toBeVisible()
},
target: p => stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }),
hover: p => stop(p, 'Nishiki Market').hover(),
act: async p => {
await stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }).click()
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
await settle(p)
},
},
],
// The new booking is never saved, so there is nothing to delete.
cleanup: closeModal,
},
'place-files': {
guide: guide('place-files'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, 'teamLab Planets')
},
steps: [
only(filesToggle),
{
target: p => card(p).getByText('Upload', { exact: true }),
act: async p => {
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The list opens itself once the upload lands.
only(p => card(p).getByRole('button', { name: '1 file', exact: true })),
// No click: openFile hands the browser a download.
only(p => card(p).getByRole('button', { name: UPLOADED })),
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/files`)
const body = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of (body.files ?? []).filter(f => f.original_name === UPLOADED)) {
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
await p.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
},
},
'place-navigation': {
guide: guide('place-navigation'),
start: async p => {
await openTrip(p, { day: null })
await openWithHours(p, q => openPlace(q, PLACE_WEBSITE.place), { place: PLACE_WEBSITE.place })
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Navigation' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Navigation' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
only(p => p.getByRole('menu')),
{
// Clicking one opens an external tab, so the menu is closed instead.
target: p => p.getByRole('menuitem', { name: 'Google Maps' }),
act: async p => {
await p.keyboard.press('Escape')
await expect(p.getByRole('menu')).toHaveCount(0)
},
},
only(p => footer(p).getByRole('button', { name: 'Open Website' })),
],
},
'place-to-collection': {
guide: guide('place-to-collection'),
start: async p => {
await unsaveEverywhere(p, 'Senso-ji Temple')
await openTrip(p, { day: null })
await openWithHours(p, q => openPlace(q, 'Senso-ji Temple'))
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Save to Collection' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Save to Collection' }).click()
await expect(modal(p).getByRole('heading', { name: 'Save to list' })).toBeVisible()
await settle(p)
},
},
only(dialog),
{
// Anchored, not exact: the Collections guides leave a list behind whose
// name opens with this one's, and once the place is saved the row's
// name grows the status chip (Idea, Planned, Visited).
target: p => modal(p).getByRole('button', { name: LIST_ROW }),
act: async p => {
await modal(p).getByRole('button', { name: LIST_ROW }).click()
await expect(modal(p).getByRole('button', { name: LIST_ROW })).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Close' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Close' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(footer(p).getByRole('button', { name: 'Saved' })).toBeVisible({ timeout: 20_000 })
},
},
],
cleanup: p => unsaveEverywhere(p, 'Senso-ji Temple'),
},
'place-track': {
guide: guide('place-track'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => row(p, TRACK),
act: async p => {
await openPlace(p, TRACK)
await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible()
},
},
only(p => section(p, 'Track Stats')),
{
target: p => card(p).getByRole('button', { name: 'Track color' }),
act: async p => {
await card(p).getByRole('button', { name: 'Track color' }).click()
await expect(swatch(p)).toBeVisible()
await settle(p)
},
},
{
target: swatch,
act: async p => {
await swatch(p).click()
await expect(swatch(p)).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
// No click: it would undo the result picture. The reset happens in cleanup.
only(p => card(p).getByRole('button', { name: 'Automatic color' })),
],
cleanup: p => patchPlace(p, TRACK, { route_color: TRACK_IMPORT_COLOR }),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
await ensureDaysFixtures(request)
await ensurePlaceFixtures(request)
})
test('every registered place guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlaceContext.guides].sort())
})
test('hero: trip-place', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripPlaceContext.id, async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
await openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ })
})
})
for (const id of tripPlaceContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+519
View File
@@ -0,0 +1,519 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, gpxFixture, ensureTrack } from './fixtures'
import { openTrip, modal, dialog, confirmDialog } from './trip-shared'
import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the places column of a trip, keyed by the ids in
* `src/help/contexts/tripPlaces.ts`. They run on the seeded "Autumn in Japan"
* with one walk imported up front, so the Tracks filter exists; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripPlacesGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
/** What create-place looks up. See the note in its second step for why this one. */
const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ }
/** The search result create-place picks. */
const suggestion = (page: Page) =>
modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first()
/** A row of the places column by the place's name (the rows are options of a list). */
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */
const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last()
/** The add button: "Add Place/Activity" with no day open, "New place" with one. */
const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ })
/**
* The head band of the column: the add row, the search with the select switch,
* and the row of filters. Found from its import button, the one control only
* this band has, so the plain "Search" of the field is read inside it.
*/
const head = (page: Page) =>
page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]')
/** The import button's menu: Import file and the list import. */
const importMenu = (page: Page) =>
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last()
/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */
const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true })
const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..')
/** The category filter: a tag button whose list sits right after it. */
const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true })
const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]')
const searchBox = (page: Page) => head(page).getByPlaceholder('Search', { exact: true })
/** The selection bar at the foot of the column, found by its delete action. */
const selectionBar = (page: Page) => page.getByRole('button', { name: 'Delete selected' }).locator('xpath=..')
/** FileImportModal, recognised by its hidden file input. */
const importFileDialog = (page: Page) =>
page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') })
/** ListImportModal, recognised by the link box. */
const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) })
/** The drop box of the file dialog: the dashed button that opens the file picker. */
const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first()
/** The column itself: the header block around the search box. */
const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]')
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => names.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
const sortList = (page: Page) => sortTrigger(page).locator('xpath=following-sibling::div[1]')
/** A name no index knows, so the search comes back empty and offers Add by hand. */
const HANDMADE = { name: 'Kissaten Hoshizora Ura', phone: '+81 75 000 1234', email: 'hello@hoshizora.example' }
const SCRIPTS: Record<string, GuideScript> = {
'sort-places': {
guide: guide('sort-places'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await sortTrigger(p).click()
await expect(sortList(p)).toBeVisible()
await beat(p, 300)
},
target: sortList,
},
{
target: p => sortList(p).getByRole('button', { name: 'Highest rated' }),
act: async p => {
await sortList(p).getByRole('button', { name: 'Highest rated' }).click()
await expect(sortList(p)).toHaveCount(0)
await settle(p)
},
},
],
cleanup: async p => {
await sortTrigger(p).click()
await sortList(p).getByRole('button', { name: 'Recently added' }).click()
},
},
'place-by-hand': {
guide: guide('place-by-hand'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await addButton(p).click()
const box = modal(p).getByPlaceholder('Search places...')
await expect(box).toBeVisible()
await typeInto(p, box, HANDMADE.name)
await box.press('Enter')
await expect(modal(p).getByText(/^Nothing found for/)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
target: p => modal(p).getByRole('button', { name: 'Add by hand' }).locator('xpath=..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add by hand' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add by hand' }).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(HANDMADE.name)
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HANDMADE.phone)
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HANDMADE.email)
await settle(p)
},
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Add opening hours' }).click()
await expect(modal(p).getByRole('button', { name: 'Remove opening hours' })).toBeVisible()
await modal(p).getByRole('button', { name: 'Remove opening hours' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[.//input][1]'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, HANDMADE.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, HANDMADE.name),
},
'create-place': {
guide: guide('create-place'),
start: p => openTrip(p),
steps: [
{
// A running trip always has a day open, so the button reads New place.
target: addButton,
act: async p => {
await addButton(p).click()
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
await settle(p)
},
},
{
// The results are pictured before the pick: once one is taken the
// search box empties again, and a ring round an empty box shows the
// reader nothing of what the step says.
prepare: async p => {
const box = modal(p).getByPlaceholder('Search places...')
// Osaka Castle on purpose: its entry in the index carries a picture,
// opening hours and a description, so Place details shows what the
// text says it shows. Most results have none of that and the column
// would be an empty state in the picture.
await typeInto(p, box, SEARCHED.query)
await expect(suggestion(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => modal(p).locator('.shadow-dropdown').first(),
hover: p => suggestion(p).hover(),
act: async p => {
await suggestion(p).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 })
// The details column answers after the form; wait for it or the
// picture catches the spinner.
await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The details column is an <aside> beside the form; both used to resolve to
// the whole dialog, which gave two steps the same picture.
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::aside[1]')),
// The name and the category sit in the head band, above the form, so the
// step about the fields shows the dialog rather than the form alone.
only(p => dialog(p)),
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, SEARCHED.query)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SEARCHED.query),
},
'place-to-open-day': {
guide: guide('place-to-open-day'),
start: async p => {
await createSpare(p)
await openTrip(p, { day: null })
},
steps: [
{
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
act: async p => {
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Add to the open day' }),
act: async p => {
await p.getByRole('button', { name: 'Add to the open day' }).click()
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
await beat(p, 400)
await closeModal(p)
},
},
{
prepare: async p => { await row(p, SPARE.name).hover() },
target: p => row(p, SPARE.name).getByRole('button', { name: '+ Day' }),
act: async p => {
await row(p, SPARE.name).getByRole('button', { name: '+ Day' }).click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
},
{
// The other way round, and the one most readers try first: the row is
// dragged onto a day card. The place is off the day again before the
// drag so the picture shows it leaving the column rather than a stop
// that is already there.
prepare: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(row(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: p => row(p, SPARE.name),
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
act: async p => {
await row(p, SPARE.name).dragTo(p.getByRole('button', { name: /^2 .*Day 2 / }))
await expect(p.locator('.dp-row[role="button"]').filter({ hasText: SPARE.name }).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'filter-places': {
guide: guide('filter-places'),
start: async p => {
await createSpare(p)
await openTrip(p)
},
steps: [
{
prepare: async p => {
await showTrigger(p).click()
await expect(showList(p)).toBeVisible()
await beat(p, 300)
},
target: showList,
// The lists close on a second click of their trigger.
act: async p => { await showTrigger(p).click(); await settle(p) },
},
{
prepare: async p => { await typeInto(p, searchBox(p), 'temple'); await settle(p) },
target: p => searchBox(p).locator('xpath=..'),
act: async p => { await searchBox(p).fill(''); await settle(p) },
},
{
// One category ticked, so the list shows the tick, the button its count
// and the foot of the list its Clear filter.
prepare: async p => {
await categoryTrigger(p).click()
await expect(categoryList(p)).toBeVisible()
// No Category: the seed's places carry none, so it is the choice that
// leaves rows in the list rather than an empty column.
await categoryList(p).getByRole('button', { name: /No Category/ }).click()
await expect(categoryList(p).getByRole('button', { name: 'Clear filter' })).toBeVisible()
// The list scrolls inside itself; its foot is where the tick and Clear filter are.
await categoryList(p).evaluate(el => { el.scrollTop = el.scrollHeight })
await beat(p, 300)
},
target: categoryList,
act: async p => {
await categoryList(p).getByRole('button', { name: 'Clear filter' }).click()
await categoryTrigger(p).click()
await expect(categoryList(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click(); await beat(p, 300) },
target: p => p.getByRole('button', { name: 'Filter by rating' }),
act: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click() },
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'edit-place': {
guide: guide('edit-place'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await row(p, 'Shibuya Crossing').click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: async p => {
await menu(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue('Shibuya Crossing')
await settle(p)
},
},
only(dialog),
{
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: closeModal,
},
],
},
'delete-place': {
guide: guide('delete-place'),
start: async p => {
await createSpare(p)
await openTrip(p)
},
steps: [
{
prepare: async p => {
await row(p, SPARE.name).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Delete' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Delete' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Delete' }).click()
await expect(confirmDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: confirmDialog,
act: async p => {
await confirmDialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(row(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'select-places': {
guide: guide('select-places'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Select', exact: true }).click()
await expect(p.getByRole('button', { name: 'Select all' })).toBeVisible()
await settle(p)
},
},
{
// Two rows next to each other, so one picture shows both ticks.
prepare: async p => {
await row(p, 'Shibuya Crossing').click()
await row(p, 'Meiji Jingu').click()
await expect(p.getByRole('status', { name: '2 selected' })).toBeVisible()
await beat(p, 300)
},
target: p => row(p, 'Meiji Jingu'),
},
only(selectionBar),
{
target: p => selectionBar(p).getByRole('button', { name: 'Done' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Done' }).click()
await expect(p.getByRole('button', { name: 'Select all' })).toHaveCount(0)
},
},
],
},
'import-places-file': {
guide: guide('import-places-file'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await p.getByRole('button', { name: 'Import Places' }).click()
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
target: p => importMenu(p).getByRole('button', { name: 'Import file' }),
act: async p => {
await importMenu(p).getByRole('button', { name: 'Import file' }).click()
await expect(importFileDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await importFileDialog(p).locator('input[type="file"]').setInputFiles(gpxFixture('arashiyama-loop'))
await expect(importFileDialog(p).getByText('Tracks (with path geometry)')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: importFileDialog,
},
only(dropBox),
{
target: p => importFileDialog(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await importFileDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(importFileDialog(p)).toHaveCount(0, { timeout: 30_000 })
await expect(row(p, 'Arashiyama loop')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
},
'import-places-list': {
guide: guide('import-places-list'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await p.getByRole('button', { name: 'Import Places' }).click()
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
// Google List while Google is the only list provider, List Import once Naver is on too.
target: p => importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }),
act: async p => {
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
await expect(listImportDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, listImportDialog(p).getByPlaceholder(/goo\.gl|naver/), 'https://maps.app.goo.gl/kyoto-favourites')
await beat(p, 300)
},
target: listImportDialog,
},
only(p => listImportDialog(p).getByRole('button', { name: 'Import', exact: true })),
],
// The dialog has no Escape route: Cancel is the way out.
cleanup: async p => {
if (await listImportDialog(p).isVisible().catch(() => false)) {
await listImportDialog(p).getByRole('button', { name: 'Cancel' }).click()
await expect(listImportDialog(p)).toHaveCount(0)
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
})
test('every registered places guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlacesContext.guides].sort())
})
test('hero: trip-places', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripPlacesContext.id, p => openTrip(p))
})
for (const id of tripPlacesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+760
View File
@@ -0,0 +1,760 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for road trip mode, keyed by the ids in
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
* guide writes it takes away again in `cleanup`, and `test.afterAll` switches
* the addon off again: it is an instance-wide setting, and while it is on
* every trip's left column grows a switch the other screens never show.
*/
const guide = (id: string): HelpGuide => {
const g = tripRoadtripGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
// ── Where things are ──────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
const dayCard = (page: Page, day: number) =>
rail(page)
.locator('> section')
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
.first()
/**
* One stop's row and everything hanging off it. The kind disc sits inside the
* row button while the stop is a destination and beside it once it is a stop on
* the way, so the list item is the only handle that holds for both.
*/
const stopItem = (page: Page, name: string) => rail(page).getByRole('listitem').filter({ hasText: name }).first()
const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('button').filter({ hasText: name }).first()
const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' })
/** The corridor panel: before a search its heading is the only stable thing in it. */
const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]')
/**
* The two dropdowns of the Looking for line. Both are listbox triggers, so they
* are told apart by name: the kinds read "Looking for: Fuel", the day "Day 1".
*/
const kindPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Looking for/ })
const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first()
/** The list a dropdown opens; only one is open at a time. */
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
/** The drive band of day 4's long leg, the one with more than one road to it. */
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
/** The alternatives bar floats over the map, inside neither column. */
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
const dryBand = (page: Page) => page.getByText('Tank runs out here').first().locator('xpath=ancestor::div[3]')
const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first()
/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */
const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first()
// ── Getting there ─────────────────────────────────────────────────────────────
async function openRoadtrip(page: Page): Promise<void> {
await openTrip(page, { day: null })
await page.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(page)
}
/**
* Every leg of every day is a router round trip, and the rail says nothing at
* all until they land. The totals appear first and the partial line only while
* something is outstanding, so neither of them on its own says the drive is
* there; a drive band on a day that really has one does.
*/
async function waitForTheDrive(page: Page): Promise<void> {
await expect(page.getByText('Driving time', { exact: true })).toBeVisible({ timeout: 120_000 })
await expect(dayCard(page, 2).getByRole('button', { name: /Other ways$/ }).first()).toBeVisible({ timeout: 120_000 })
await expect(page.getByText('Still working out the rest of the drive')).toHaveCount(0, { timeout: 120_000 })
await settle(page)
}
/**
* Put a leg where the pointer can reach it. The map opens fitted to the whole
* trip, which leaves the drawn route under the two floating panels; clicking a
* stop frames that stop instead, and the card it opens is closed again at once.
*/
async function frameOnStop(page: Page, name: string): Promise<void> {
// On the name: the row's centre can land on its Stay badge, which opens its
// own dialog instead of selecting the stop.
await stopRow(page, name).click({ position: { x: 70, y: 12 } })
// The place's card opens over the map; its Close sits in the card's head band.
const head = placeCardHead(page, name)
await expect(head).toBeVisible({ timeout: 15_000 })
await finishMapPan(page)
await head.getByRole('button', { name: 'Close', exact: true }).click()
await expect(head).toHaveCount(0)
await settle(page)
}
/**
* Let the map's pan to a picked place land. Leaflet times its pan from
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
* the pan starts and never gets anywhere: the map stays where it was. Moving the
* pinned clock a few seconds on ends it on the next frame, as a second passing
* would; nothing in these pictures reads the time to the second.
*/
async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400)
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
await page.waitForTimeout(1200)
await settle(page)
}
/**
* The head band of the card a stop opens over the map (the place inspector):
* the place's name as a heading, and Close beside it. The rail prints the same
* name, but never as a heading.
*/
const placeCardHead = (page: Page, name: string) =>
page.getByRole('heading', { name, exact: true, level: 2 }).locator('xpath=ancestor::header[1]')
type RoutePoint = { leg: number; x: number; y: number }
/**
* Points that really lie on a drawn leg, with nothing over them. A bounding-box
* centre is no use here: a route is a curve, its box is mostly empty space, and
* what sits in the middle of it is usually somebody else's pin.
*/
async function routePoints(page: Page): Promise<RoutePoint[]> {
return page.evaluate(() => {
const paths = Array.from(
document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]'),
)
const found: { leg: number; x: number; y: number }[] = []
paths.forEach((path, leg) => {
const length = path.getTotalLength()
const ctm = path.getScreenCTM()
if (!ctm || length < 24) return
for (let f = 0.08; f <= 0.92; f += 0.02) {
const at = path.getPointAtLength(length * f)
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e)
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f)
// The two panels float over the map; a point under one of them is not clickable.
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
}
})
return found
})
}
/** Which leg the via guide aims at, and where; worked out once, used by three steps. */
let aimedLeg = 0
let aimedAt = { x: 0, y: 0 }
/** The day the via landed on, read back between two steps of the same guide. */
let viaDayId = 0
/** Names of what a guide created, read back by its cleanup. */
let addedStop = ''
let refuelStop = ''
/** The leg with the most reachable points: the one worth clicking and worth a picture. */
async function aimAtLeg(page: Page): Promise<void> {
const points = await routePoints(page)
if (points.length === 0) throw new Error('no drawn leg is reachable on the map')
const counts = new Map<number, number>()
for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1)
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
aimedLeg = best ? best[0] : points[0].leg
const own = points.filter(point => point.leg === aimedLeg)
const middle = own[Math.floor(own.length / 2)] ?? points[0]
aimedAt = { x: middle.x, y: middle.y }
}
const aimedLine = (page: Page) => page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').nth(aimedLeg)
/** The pointer goes on the line, not on the centre of the box around it. */
const hoverAimedLine = async (page: Page): Promise<void> => {
await page.mouse.move(aimedAt.x, aimedAt.y)
}
// ── What the guides write, and how it is taken back ───────────────────────────
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
async function listVias(page: Page): Promise<ViaRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const body = (await res.json()) as { vias?: ViaRow[] }
return body.vias ?? []
}
async function clearVias(page: Page): Promise<void> {
const { tripId } = seededTrip()
for (const via of await listVias(page)) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`)
}
// A followed track is recorded per day even when the drive needed no via to
// follow it, and deleting vias leaves that record standing: clear it too.
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const { tracks = [] } = (await res.json()) as { tracks?: { day_id: number }[] }
for (const track of tracks) {
await page.request.post(`/api/trips/${tripId}/roadtrip/days/${track.day_id}/vias/batch`, {
data: { vias: [], track: null },
})
}
}
async function clearDayBoundaries(page: Page): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/day-boundaries`)
const body = (await res.json()) as { boundaries?: { day_number: number }[] }
for (const boundary of body.boundaries ?? []) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/day-boundaries/${boundary.day_number}`)
}
}
async function listPlaces(page: Page): Promise<{ id: number; name: string }[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
return Array.isArray(body) ? body : (body.places ?? [])
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const wanted = names.filter(name => name.length > 0)
if (wanted.length === 0) return
for (const place of (await listPlaces(page)).filter(x => wanted.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/** Put one place back the way the seed had it. */
async function restorePlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
const { tripId } = seededTrip()
const place = (await listPlaces(page)).find(x => x.name === name)
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'roadtrip-mode': {
guide: guide('roadtrip-mode'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => p.getByRole('tablist', { name: 'Plan view' }),
act: async p => {
await p.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(p)
},
},
only(p => p.getByText('Driving time', { exact: true }).locator('xpath=ancestor::header[1]')),
only(p => dayCard(p, 2).locator('header')),
only(p => dayCard(p, 2).getByRole('list').first()),
{
// Day 2, like the two steps above: the rail leaves out a day with
// nothing to drive, and day 1 is one of them.
target: p => dayCard(p, 2).locator('header'),
act: async p => {
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'false')
await settle(p)
// Folded is not the state to leave the trip in, and the result picture
// is of the whole drive rather than of one card shut.
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'true')
await settle(p)
},
},
],
},
'roadtrip-stops': {
guide: guide('roadtrip-stops'),
start: openRoadtrip,
steps: [
{
// The disc itself, by its own label: the row button around it takes the
// disc's label into its accessible name, so the role query matches both.
target: p => stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]'),
act: async p => {
await stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]').click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: kindDialog,
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Rest area' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(
stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }),
).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => dayCard(p, 2).locator('header')),
{
prepare: async p => {
await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }).click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
target: p => kindDialog(p).getByRole('button', { name: 'Back to a destination' }),
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Back to a destination' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]')).toBeVisible({
timeout: 20_000,
})
await settle(p)
},
},
{
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
act: async p => {
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: '1 h', exact: true }).click()
await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60')
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
},
},
'roadtrip-corridor': {
guide: guide('roadtrip-corridor'),
start: async p => {
addedStop = ''
await openRoadtrip(p)
},
steps: [
{
// The line reads "these kinds, along this day", so the kinds come first.
prepare: async p => {
await kindPicker(p).click()
await expect(openList(p)).toBeVisible()
await settle(p)
},
target: openList,
act: async p => {
// Fuel is what the panel opens on for a petrol car; Food is ticked beside it.
await openList(p).getByRole('option', { name: 'Food' }).click()
await expect(openList(p).getByRole('option', { name: 'Food' })).toHaveAttribute('aria-selected', 'true')
// The list stays open for a second kind; a second click on its trigger shuts it.
await kindPicker(p).click()
await expect(openList(p)).toHaveCount(0)
await expect(kindPicker(p)).toHaveAccessibleName(/Food/)
await settle(p)
},
},
{
target: dayPicker,
act: async p => {
await dayPicker(p).click()
// The rows are options named by the day's number and its label, "2 Day 2".
await openList(p).getByRole('option', { name: /\bDay 2$/ }).click()
await expect(dayPicker(p)).toContainText('Day 2')
await settle(p)
},
},
{
target: p => corridor(p).getByRole('button', { name: '5 km', exact: true }).locator('xpath=..'),
act: async p => {
// The step's sentence tells the reader to choose a width and then search,
// so the width is chosen here even though 5 km is what the panel opens on.
await corridor(p).getByRole('button', { name: '5 km', exact: true }).click()
await expect(corridor(p).getByRole('button', { name: '5 km', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
)
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
await expect(corridor(p).getByText(/on the way$/).first()).toBeVisible({ timeout: 180_000 })
await settle(p)
},
},
only(p => corridor(p).getByRole('listitem').first()),
{
prepare: async p => {
const row = corridor(p).getByRole('listitem').first()
addedStop = (await row.innerText()).split('\n')[0] ?? ''
await row.hover()
await settle(p)
},
target: p => corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible()
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === addedStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, addedStop),
},
'roadtrip-via': {
guide: guide('roadtrip-via'),
start: openRoadtrip,
steps: [
{
// A stop of day 4, the drive out of Tokyo: the one leg long enough to be
// worth bending, and once Hakone is in the middle of the map it runs
// east across the open map rather than under the right column.
target: p => stopItem(p, 'Hakone Shrine'),
act: async p => {
await frameOnStop(p, 'Hakone Shrine')
await aimAtLeg(p)
},
},
{
target: aimedLine,
hover: hoverAimedLine,
act: async p => {
await p.mouse.click(aimedAt.x, aimedAt.y)
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(1)
await expect(viaHandle(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
prepare: async p => {
viaDayId = (await listVias(p))[0]?.day_id ?? 0
await settle(p)
},
// Which day the click landed on is the router's answer, not ours.
target: p => {
const day = seededTrip().dayIds.indexOf(viaDayId) + 1
// Fall back to day 2, not day 1: the rail has no card for a day
// with nothing to drive.
return dayCard(p, day > 0 ? day : 2).locator('header')
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
const before = JSON.stringify((await listVias(p))[0] ?? {})
// A Leaflet drag, not an HTML5 one: the handle listens for the raw
// pointer, so `dragTo` would move nothing.
await p.mouse.move(from.x, from.y)
await p.mouse.down()
await p.mouse.move(from.x + 45, from.y + 40, { steps: 10 })
await p.mouse.move(from.x + 90, from.y + 80, { steps: 10 })
await p.mouse.up()
await expect
.poll(async () => JSON.stringify((await listVias(p))[0] ?? {}), { timeout: 60_000 })
.not.toBe(before)
await settle(p)
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' })
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(0)
await settle(p)
},
},
],
cleanup: clearVias,
},
'roadtrip-alternatives': {
guide: guide('roadtrip-alternatives'),
start: openRoadtrip,
steps: [
{
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
// Shibuya the router knows one sensible way, and the bar says only that.
target: altBand,
act: async p => {
await altBand(p).click()
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
only(altBar),
{
prepare: async p => {
await altBar(p).getByRole('button', { name: /km/ }).last().hover()
await settle(p)
},
target: p => altBar(p).getByRole('button', { name: /km/ }).last(),
},
{
target: p => altBar(p).getByRole('button', { name: 'Close' }),
act: async p => {
await altBar(p).getByRole('button', { name: 'Close' }).click()
await expect(p.getByText('Ways to drive this leg')).toHaveCount(0)
await settle(p)
},
},
],
// Nothing is chosen here, but choosing an entry writes a via, so a misclick
// must not follow the run into the next guide.
cleanup: clearVias,
},
'roadtrip-limits': {
guide: guide('roadtrip-limits'),
start: openRoadtrip,
steps: [
{
target: settingsCard,
act: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-legMinutes').locator('xpath=ancestor::section[1]'),
act: async p => {
await modal(p).getByTestId('limit-legMinutes').fill('60')
await p.keyboard.press('Tab')
await expect(modal(p).getByTestId('limit-legMinutes')).toHaveValue('60')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'What you drive' }),
act: async p => {
await modal(p).getByText('Electric', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'Electric' })).toBeChecked()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-range').locator('xpath=ancestor::label[1]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Work it out from the car' }).click()
await expect(modal(p).getByRole('spinbutton', { name: /^Battery, / })).toBeVisible()
await settle(p)
},
},
// Not toggled on purpose: each of these switches sends every leg of the
// trip back through the avoidance router, which is minutes of waiting for
// one picture of a row of switches.
// By its title, not by a switch: without an avoidance router the switches
// are replaced by a dash and the section is all that is left of it.
only(p => modal(p).getByText('Avoid where possible', { exact: true }).locator('xpath=ancestor::section[1]')),
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
// Both halves: the badge the fixture leaves reads "1 h 30 min", which
// contains "1 h", so the shortened limit only shows in what is NOT there.
await expect(settingsCard(p)).toContainText('1 h', { timeout: 30_000 })
await expect(settingsCard(p)).not.toContainText('1 h 30 min')
await settle(p)
},
},
],
// The fixture writes the whole preference set, so re-running it is the undo.
cleanup: p => ensureRoadtripFixtures(p.request),
},
'roadtrip-day-window': {
guide: guide('roadtrip-day-window'),
start: openRoadtrip,
steps: [
{
prepare: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
target: p => modal(p).getByRole('group', { name: 'Daily travel times' }),
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day start time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day start time' }).fill('09:00')
await expect(modal(p).getByRole('textbox', { name: 'Day start time' })).toHaveValue('09:00')
await settle(p)
},
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day end time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day end time' }).fill('17:30')
await p.keyboard.press('Tab')
await expect(modal(p).getByRole('textbox', { name: 'Day end time' })).toHaveValue('17:30')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'End the day' }),
act: async p => {
await modal(p).getByText('At the last place', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'At the last place' })).toBeChecked()
await settle(p)
},
},
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
await expect(settingsCard(p)).toContainText('09:00', { timeout: 60_000 })
await settle(p)
},
},
],
cleanup: async p => {
await clearDayBoundaries(p)
await ensureRoadtripFixtures(p.request)
},
},
'roadtrip-refuel': {
guide: guide('roadtrip-refuel'),
start: async p => {
refuelStop = ''
await openRoadtrip(p)
},
steps: [
only(dryBand),
{
target: p => p.getByRole('button', { name: 'Find fuel' }).first(),
act: async p => {
await p.getByRole('button', { name: 'Find fuel' }).first().click()
await expect(p.getByText('Looking along the route…')).toHaveCount(0, { timeout: 180_000 })
await expect(addFuelStop(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => dryBand(p).getByRole('list').first()),
{
prepare: async p => {
const label = (await addFuelStop(p).getAttribute('aria-label')) ?? ''
refuelStop = label.replace(/^Add /, '').replace(/ as a fuel stop$/, '')
await addFuelStop(p).hover()
await settle(p)
},
target: addFuelStop,
act: async p => {
await addFuelStop(p).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === refuelStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, refuelStop),
},
'roadtrip-track': {
guide: guide('roadtrip-track'),
start: openRoadtrip,
steps: [
{
target: p => dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }),
act: async p => {
await dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Day 7 follows a track' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('list').first(),
act: async p => {
await modal(p).getByRole('button', { name: /^Arashiyama loop/ }).click()
await expect(modal(p).getByRole('button', { name: 'Follow this track' })).toBeEnabled()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Follow this track' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Follow this track' }).click()
// One router round trip per refinement round, on a route that is
// being pulled onto a track metre by metre.
await expect(modal(p).getByText(/via points placed|already followed this track/)).toBeVisible({
timeout: 240_000,
})
await settle(p)
},
},
{
target: dialog,
act: async p => {
await closeModal(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await clearVias(p)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureRoadtripFixtures(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: left on, every trip's left column grows the
// Days / Road trip switch and every other screen's pictures change. Here
// rather than in the last guide's cleanup, so a failed run puts it back too.
await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } })
})
test('every registered road trip guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripRoadtripContext.guides].sort())
})
test('hero: trip-roadtrip', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripRoadtripContext.id, openRoadtrip)
})
for (const id of tripRoadtripContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+200
View File
@@ -0,0 +1,200 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { dismissReleaseNotice, settle, type StepAction } from './guide'
import { seededTrip } from './fixtures'
/**
* What every trip screen's script needs: the way into the seeded trip, the
* day selection and the dialogs. Not a `.guide.ts`, so importing it registers
* no tests twice.
*/
export const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
export const dialog = (page: Page) => modal(page).locator('> [role="presentation"], > div').first()
/**
* ConfirmDialog is its own portal and carries `trek-backdrop-enter` without the
* `trek-modal-backdrop` of `Modal`, so the yes/no questions need their own
* locator: `modal` would never see them.
*/
export const confirmBackdrop = (page: Page) => page.locator('.trek-backdrop-enter:not(.trek-modal-backdrop)').last()
export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.trek-modal-enter').last()
/**
* The import dialogs of the places column are portals of their own with no
* shared backdrop class at all, so they are found by what stands inside them.
* Both the overlay and the card carry `role="presentation"`, and the card comes
* second in document order, which is the one worth a picture.
*/
export const portalDialog = (page: Page, contains: Locator): Locator =>
page.locator('div[role="presentation"]').filter({ has: contains }).last()
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
const { tripId } = seededTrip()
const { tab, day = 1 } = opts
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
await untilTranslated(page)
if (!tab && day) {
// The trip is running, so the plan opens on today, an empty day at the
// bottom. Day 1 has the places; its details panel would cover the map.
await selectDay(page, day)
await closeDayDetails(page)
}
// The map tiles land after the page says it is idle.
await page.waitForTimeout(1200)
}
/**
* A text that is still its key: `dayplan.dayN` where "Day 1" belongs. The
* locale bundles come from `shared/dist`, and while a watcher rebuilds it a
* page can load with a part of them missing. Reloading after the rebuild has
* landed brings the strings back; a page that stays untranslated fails here
* rather than in a picture.
*/
const rawKey = (page: Page) => page.getByText(/^(common|dayplan|planner|places|reservations|trip|day|budget|files|collab|packing|todo)\.[A-Za-z0-9.]+$/)
async function untilTranslated(page: Page): Promise<void> {
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000)
await page.reload()
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
}
/** Click the day's header: selects it and opens its details panel. */
export async function selectDay(page: Page, n: number): Promise<void> {
const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
// The header carries buttons of its own: the booked night in the middle, the
// transit, note and fold actions on the right. A click on the centre lands on
// the booked night as soon as a day has one, which selects nothing, so the
// click goes to the day's number at the left edge.
await header.click({ position: { x: 22, y: 20 } })
// The details panel is the selection: without it the day's route tools, the
// + on a place row and To day are all absent, and a guide fails far from here.
await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 })
await settle(page)
}
/** The day details panel: the one fixed, floating card over the map. */
export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first()
/**
* The details panel's own Close, in its head band. Found inside the panel: the
* last X of the page is not always this one once the day carries bookings.
*/
export async function closeDayDetails(page: Page): Promise<void> {
const close = dayDetails(page).getByRole('button', { name: 'Close', exact: true })
if (await close.isVisible().catch(() => false)) {
await close.click()
await settle(page)
}
}
/**
* Open the trip with day `n` selected and its details panel standing. The panel
* IS the selection — closing it deselects the day — and the day's route tools
* and the "+" at the end of a place row render for a selected day only, so a
* guide about either of them starts here rather than at `openTrip`.
*/
export async function openTripOnDay(page: Page, n = 1): Promise<void> {
await openTrip(page, { day: null })
await selectDay(page, n)
}
/**
* Dismiss a `ContextMenu`. It closes on a document click and ignores Escape,
* and a real click would land on the map or on the row underneath.
*/
export async function closeMenu(page: Page): Promise<void> {
await page.evaluate(() => document.body.click())
await settle(page)
}
// ── Import from file ──────────────────────────────────────────────────────────
/**
* Import from file, on the Bookings and the Transports tab alike. The dialog
* is another bare portal, told apart by its heading; the parse it starts is
* reported by the background widget, a fixed portal in the bottom-right
* corner (the one at z-index 50000) with a card per run.
*/
export const importDialog = (page: Page) =>
portalDialog(page, page.getByText('Import booking confirmations', { exact: true }))
export const importWidget = (page: Page) => page.locator('body > div[style*="z-index: 50000"]')
/** The widget's card for the run that parses `file`; the card is labelled with the file names. */
export const importTask = (page: Page, file: string) =>
importWidget(page).locator('.bg-surface-card').filter({ hasText: file }).last()
/**
* The first three steps of an import guide, the same on either tab: the
* toolbar button, the drop box with the file named on it, and Import.
*/
export function importSteps(fixture: () => string, file: string): StepAction[] {
return [
{
// An icon in the toolbar, named like the dialog it opens.
target: p => p.getByRole('button', { name: 'Import booking confirmations', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Import booking confirmations', exact: true }).click()
await expect(importDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
// Set the hidden input directly: the box opens the file picker of the
// operating system, which Playwright cannot photograph.
await importDialog(p).locator('input[type="file"]').setInputFiles(fixture())
await expect(importDialog(p).getByText(file)).toBeVisible()
await settle(p)
},
target: p => importDialog(p).getByText(file).locator('xpath=ancestor::button[1]'),
act: settle,
},
{
target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
]
}
/**
* Put the widget away after an import guide. The review takes the card with
* it when it starts, so one still standing means the run stopped before
* that, and it would sit in the corner of every later guide's pictures. Close
* is only offered once the parse has stopped, and the wait is bounded so a
* card nobody can close does not fail the guide from its cleanup.
*/
export async function dismissImportTask(page: Page, file: string): Promise<void> {
const task = importTask(page, file)
if (!(await task.isVisible().catch(() => false))) return
const close = task.getByRole('button', { name: 'Close' })
await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {})
if (await close.isVisible().catch(() => false)) await close.click()
}
/**
* Take a document a guide uploaded out of the trip for good. DELETE alone is
* the trash, and the Files guides photograph the trash, so the permanent
* delete follows.
*/
export async function deleteTripFiles(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/files`)
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of files.filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
}
+701
View File
@@ -0,0 +1,701 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureTransportFixtures, TRANSIT_JOURNEY, flightEmlFixture, FLIGHT_EML, FLIGHT_TO_IMPORT } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
import { ensureAirtrailConnection, disconnectAirtrail, AIRTRAIL_FLIGHTS, requireExtractor, allowEmlUploads } from './external'
import { tripTransportsContext, tripTransportsGuides } from '../../src/help/contexts/tripTransports'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Transports tab of a trip, keyed by the ids in
* `src/help/contexts/tripTransports.ts`. They run on the seeded "Autumn in
* Japan" with the transports `ensureTransportFixtures` adds up front, so the
* tab has a type chip per group and a planned connection to open; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripTransportsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
/**
* The connection the fixtures store on day 6, so no guide has to search for one
* first. Its card is named by its title, the two ends with an arrow between them.
*/
const JOURNEY = { title: TRANSIT_JOURNEY }
/** What plan-transit plans on day 1; the title the panel builds from the two ends. */
const PLANNED = 'Senso-ji Temple → teamLab Planets'
/** What add-transport creates. Both ends are searched, so the map has something to draw. */
const TAXI = {
title: 'Taxi to teamLab Planets',
from: { query: 'Senso-ji Temple Tokyo', match: /^Sens/ },
to: { query: 'teamLab Planets', match: /^teamLab/ },
}
/** A day card's head: number, weather, "Day n", date, its pills and the "+" with the chevron. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The "+" in a day's head; Add transport and Public transit are in the menu it opens. */
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
/** That menu: a popover of plain buttons, told apart from a tooltip by its role. */
const dayMenu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
async function openDayMenu(page: Page, n: number): Promise<void> {
await addToDay(page, n).click()
await expect(dayMenu(page).getByRole('button', { name: 'Add transport' })).toBeVisible()
await beat(page, 300)
}
/** A booking card in the tab: an <article> named by the booking's title. */
const card = (page: Page, title: string) => page.getByRole('article', { name: title }).first()
/** A card found by something it shows rather than its title, such as a booking code. */
const cardShowing = (page: Page, text: string) => page.getByRole('article').filter({ hasText: text }).first()
/** The two popovers of the tab's toolbar, each opened by its own button. */
const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getByRole('button', { name, exact: true })
const toolbarMenu = (page: Page, name: 'Filter' | 'View options') => toolbarButton(page, name).locator('xpath=../..').getByRole('menu')
/** A transport's row in the day plan; the whole row is the button that opens it. */
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
/** The booking a click on a row or a card opens, named by the booking's title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The Edit at the foot of that booking, which leads on to its form. */
const detailEdit = (page: Page, title: string) => bookingDetail(page, title).getByRole('button', { name: 'Edit', exact: true })
/** A block of the open dialog by its heading: every block is a <section> that opens with it. */
const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).first().locator('xpath=ancestor::section[1]')
/** The head band of the open dialog, where a booking's title is typed and its pills sit. */
const headBand = (page: Page) => modal(page).locator('header').first()
/** The Booking Type pill in that band, and the list it opens (a portal named after it). */
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */
const legMenu = (page: Page) =>
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last()
/** The travel-time connector between two stops of the open day. */
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
/** The transit search's stop boxes, and the transport form's location boxes. */
const stopBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search stop or station…').nth(n)
const locationBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search station, port, address…').nth(n)
/**
* Both pickers are an input in its own relative wrapper with the result list as
* the wrapper's second child, so one expression finds either one's suggestions.
*/
const suggestions = (box: Locator) => box.locator('xpath=../..').locator('> div').nth(1)
/** The transit stop picker's list is portaled onto the page, fixed under its input. */
const stopSuggestions = (page: Page) => page.locator('xpath=/html/body/div[contains(@class,"--z-toast")]').last()
/** A result of the transit search: the card's own toggle carries the times as its name. */
const itinerary = (page: Page, n: number) =>
modal(page).getByRole('button', { name: /^\d{1,2}:\d{2} – \d{1,2}:\d{2} / }).nth(n)
async function searchLocation(page: Page, box: Locator, query: string, match: RegExp): Promise<void> {
await box.click()
await typeInto(page, box, query)
const options = suggestions(box).locator('button')
await expect(options.first()).toBeVisible({ timeout: 25_000 })
// The suggestion that answers the query, not whatever came back first: this
// list is a live geocoder and its top hit for a landmark is as often a shop
// down the road, which then travels into the picture as the wrong address.
const wanted = options.filter({ hasText: match }).first()
await expect(wanted, `no suggestion for "${query}" matching ${match}`).toBeVisible({ timeout: 25_000 })
await wanted.click()
await expect(box).toHaveValue(match, { timeout: 15_000 })
}
/** The transit search offers the day's own stops as soon as the box has focus. */
async function pickStop(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
const option = stopSuggestions(page).getByRole('button', { name })
await expect(option).toBeVisible({ timeout: 20_000 })
await option.click()
await expect(box).toHaveValue(name, { timeout: 10_000 })
}
type ReservationRow = { id: number; title: string; confirmation_number: string | null }
async function deleteWhere(page: Page, matches: (r: ReservationRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
const list = Array.isArray(body) ? body : (body.reservations ?? [])
for (const r of list.filter(matches)) {
await page.request.delete(`/api/trips/${tripId}/reservations/${r.id}`)
}
}
const deleteByTitle = (page: Page, ...titles: string[]) => deleteWhere(page, r => titles.includes(r.title))
/** The imported flight's title is the extractor's; its booking code is the fixture's. */
const deleteByCode = (page: Page, code: string) => deleteWhere(page, r => r.confirmation_number === code)
/**
* What import-transport-file reads out of its e-ticket. The title is the
* mapper's (airline and number joined), so the form and the card are checked
* by what the fixture owns: the code, and the two airports the built-in table
* resolves from their IATA codes.
*/
const IMPORTED = { code: FLIGHT_TO_IMPORT.code, from: /\(HND\)$/, to: /\(FRA\)$/ }
/** The two AirportSelect boxes of a flight's form, departure first. */
const airportBoxes = (page: Page) => modal(page).getByPlaceholder('Airport code or city (e.g. FRA)')
/**
* What the AirTrail account holds (external.ts): two flights home on the last
* day that connect in Tokyo, which the picker offers to join, and the Lisbon
* weekend in spring for its Other flights. The joined booking's title is the
* mapper's route, the airport codes joined by arrows (airtrail.mapper.ts).
*/
const [OUT_LEG, HOME_LEG, , OTHER] = AIRTRAIL_FLIGHTS
const JOINED = { title: `${OUT_LEG.from} → ${OUT_LEG.to} → ${HOME_LEG.to}`, layover: OUT_LEG.to }
/** The toolbar's AirTrail button: an icon named like the picker it opens. */
const airtrailButton = (page: Page) => page.getByRole('button', { name: 'Import from AirTrail', exact: true })
/** The flight picker is a bare portal like the booking import; its title tells it apart. */
const airtrailPicker = (page: Page) => portalDialog(page, page.getByText('Import from AirTrail', { exact: true }))
/** A flight's row in the picker: a button named by the airline and the flight number. */
const flightRow = (page: Page, flightNumber: string) =>
airtrailPicker(page).getByRole('button', { name: new RegExp(`\\b${flightNumber}\\b`) })
/** The join tick under a connection's legs, and the framed group it belongs to. */
const joinToggle = (page: Page) =>
airtrailPicker(page).getByRole('button', { name: /^Import as one flight with a layover in / })
const connectionGroup = (page: Page) => joinToggle(page).locator('xpath=..')
const importCta = (page: Page) => airtrailPicker(page).getByRole('button', { name: /^Import \d+$/ })
/** Give a day's legs back to the day's own mode, whichever stop carries the one that was set. */
async function resetLegModes(page: Page, dayIndex: number): Promise<void> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: { id: number }[] }
for (const a of body.assignments ?? []) {
await page.request.put(`/api/trips/${tripId}/assignments/${a.id}/transport`, { data: { transport_mode: null } })
}
}
/** The day route toggle stays on until it is clicked again; it is only there while a day is open. */
async function switchRouteOff(page: Page): Promise<void> {
const route = page.getByRole('button', { name: 'Route', exact: true }).first()
if (await route.isVisible().catch(() => false)) {
await route.click()
await settle(page)
}
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'transports-list': {
guide: guide('transports-list'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
only(p => p.getByRole('button', { name: 'Transports', exact: true })),
{
// The menu with Train ticked, so the picture shows a filter in force.
prepare: async p => {
await toolbarButton(p, 'Filter').click()
await expect(toolbarMenu(p, 'Filter')).toBeVisible()
await toolbarMenu(p, 'Filter').getByRole('button', { name: /^Train\b/ }).click()
await expect(card(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
target: p => toolbarMenu(p, 'Filter'),
act: async p => {
await toolbarMenu(p, 'Filter').getByRole('button', { name: 'Reset filters' }).click()
await expect(card(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toBeVisible()
await settle(p)
},
target: p => toolbarMenu(p, 'View options'),
act: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toHaveCount(0)
await settle(p)
},
},
only(p => card(p, FLIGHT.title)),
{
prepare: async p => {
await card(p, FLIGHT.title).getByRole('button', { name: 'Delete' }).click()
await expect(p.getByText('Delete booking?')).toBeVisible()
await beat(p, 300)
},
target: p => portalDialog(p, p.getByText('Delete booking?')),
// Cancel, not Confirm: the flight has to survive for the next guide.
act: async p => {
await portalDialog(p, p.getByText('Delete booking?')).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByText('Delete booking?')).toHaveCount(0)
await expect(card(p, FLIGHT.title)).toBeVisible()
},
},
],
cleanup: async p => {
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
'add-transport': {
guide: guide('add-transport'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Add transport' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Add transport' }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typePill(p).click()
await expect(typeMenu(p)).toBeVisible()
await beat(p, 300)
},
target: typeMenu,
act: async p => {
await typeMenu(p).getByRole('button', { name: /^Taxi$/ }).first().click()
await expect(typeMenu(p)).toHaveCount(0)
await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), TAXI.title)
await settle(p)
},
target: headBand,
},
{
prepare: async p => {
await searchLocation(p, locationBox(p, 0), TAXI.from.query, TAXI.from.match)
await searchLocation(p, locationBox(p, 1), TAXI.to.query, TAXI.to.match)
await settle(p)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
},
{
prepare: async p => {
// Between the two stops of day 1, which the fixtures give 09:30 and 13:00.
await modal(p).getByPlaceholder('00:00').nth(0).fill('12:00')
await modal(p).getByPlaceholder('00:00').nth(1).fill('12:30')
await settle(p)
},
target: p => modal(p).getByText('Date', { exact: true }).locator('xpath=../..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(transportRow(p, new RegExp(TAXI.title))).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, TAXI.title),
},
'import-transport-file': {
guide: guide('import-transport-file'),
// See import-booking-file: the extractor and no addon, and the mail has to
// be an allowed file type for the review to keep it under Files.
start: async p => {
await requireExtractor(p.request)
await allowEmlUploads(p.request, true)
await openTrip(p, { tab: 'transports' })
},
steps: [
...importSteps(flightEmlFixture, FLIGHT_EML),
{
prepare: async p => {
await expect(importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
await settle(p)
},
target: p => importTask(p, FLIGHT_EML),
act: async p => {
await importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible({ timeout: 20_000 })
await expect(modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')).toHaveValue(/203/)
// Both airports came out of the built-in table by their codes: the
// airport boxes are the two with an airport code in brackets.
await expect(airportBoxes(p).nth(0)).toHaveValue(IMPORTED.from)
await expect(airportBoxes(p).nth(1)).toHaveValue(IMPORTED.to)
await expect(modal(p).getByPlaceholder('e.g. ABC12345')).toHaveValue(IMPORTED.code)
await expect(modal(p).getByText(FLIGHT_EML)).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await expect(cardShowing(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await dismissImportTask(p, FLIGHT_EML)
await allowEmlUploads(p.request, false)
await p.evaluate(() => {
try { localStorage.removeItem('trek.bg-import-tasks') } catch { /* nothing was stored */ }
})
await deleteByCode(p, IMPORTED.code)
await deleteTripFiles(p, FLIGHT_EML)
},
},
'plan-transit': {
guide: guide('plan-transit'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Public transit' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Public transit' }).click()
await expect(modal(p).getByRole('heading', { name: 'Public transit' })).toBeVisible()
await settle(p)
},
},
{
// The quick picks open on focus, so the day's own stops are in the picture.
prepare: async p => {
await stopBox(p, 0).click()
await expect(stopSuggestions(p).getByRole('button', { name: 'Senso-ji Temple' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
act: async p => {
await pickStop(p, stopBox(p, 0), 'Senso-ji Temple')
await pickStop(p, stopBox(p, 1), 'teamLab Planets')
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeEnabled()
await settle(p)
},
},
{
// Leave when Senso-ji ends. The search opens on 09:00, and a connection
// from there would land in the day before the stop it starts from.
prepare: async p => {
const time = modal(p).getByPlaceholder('00:00')
await time.fill('11:00')
await time.blur()
await expect(time).toHaveValue('11:00')
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Depart' }).locator('xpath=ancestor::div[3]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Fewer transfers' }).click()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Tram' }).locator('xpath=ancestor::div[2]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
prepare: async p => {
await itinerary(p, 0).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => itinerary(p, 0).locator('xpath=..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect(transportRow(p, /Senso-ji Temple.*teamLab Planets/)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, PLANNED),
},
'change-transit-route': {
guide: guide('change-transit-route'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
target: p => card(p, JOURNEY.title),
act: async p => {
await card(p, JOURNEY.title).click()
await expect(bookingDetail(p, JOURNEY.title)).toBeVisible()
await settle(p)
},
},
only(p => bookingDetail(p, JOURNEY.title).getByText('Itinerary', { exact: true }).locator('xpath=ancestor::section[1]')),
{
target: p => bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }),
act: async p => {
await bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }).click()
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await itinerary(p, 1).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
// The connection now carries another itinerary: put the canned one back.
cleanup: async p => {
await deleteByTitle(p, JOURNEY.title)
await ensureTransportFixtures(p.request)
},
},
'leg-travel-mode': {
guide: guide('leg-travel-mode'),
// The route tools only render for the day that is open, and closing its
// details panel closes the day again, so day 1 stays open here.
start: p => openTripOnDay(p, 1),
steps: [
{
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(connector),
{
prepare: async p => {
await connector(p).click()
await expect(legMenu(p)).toBeVisible()
await beat(p, 300)
},
target: legMenu,
},
{
target: p => legMenu(p).getByRole('button', { name: 'Walking' }),
act: async p => {
await legMenu(p).getByRole('button', { name: 'Walking' }).click()
// The menu closing is the change landing. Not the icon on the first
// leg: the day re-routes when a mode changes, and which leg comes
// first is not fixed while it does.
await expect(legMenu(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
await resetLegModes(p, 0)
},
},
'edit-transport': {
guide: guide('edit-transport'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row),
act: async p => {
await transportRow(p, FLIGHT.row).click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
target: p => detailEdit(p, FLIGHT.title),
act: async p => {
await detailEdit(p, FLIGHT.title).click()
await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible()
await settle(p)
},
},
only(p => block(p, 'Route')),
{
// Escape rather than Update: nothing about the seeded flight may change.
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: closeModal,
},
],
},
'transport-on-map': {
guide: guide('transport-on-map'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }),
act: async p => {
await transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }).click()
await expect(transportRow(p, FLIGHT.row).getByRole('button', { name: 'Hide booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
// The map is on Tokyo, so the Frankfurt end is off screen and the Haneda
// one, drawn second, is the marker the reader can actually see.
only(p => p.locator('.trek-endpoint-marker').last()),
{
prepare: async p => {
await p.locator('.trek-endpoint-marker').last().click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, FLIGHT.title),
act: async p => {
await p.keyboard.press('Escape')
await expect(bookingDetail(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Show all booking routes' }),
act: async p => {
await p.getByRole('button', { name: 'Show all booking routes' }).click()
await expect(p.getByRole('button', { name: 'Hide all booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
{
// Day 6 carries the stored connection, and it has the two located stops
// the route tools need, so its Route toggle is there to switch on.
prepare: async p => {
await selectDay(p, 6)
await expect(p.getByRole('button', { name: 'Route', exact: true }).first()).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await settle(p)
await p.waitForTimeout(1500)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
const { tripId } = seededTrip()
await p.evaluate(id => {
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
}, tripId)
},
},
'airtrail-import': {
guide: guide('airtrail-import'),
// The connection is made here and taken away in cleanup, so the hero and
// the guides before this one keep a toolbar without the AirTrail button.
start: async p => {
await ensureAirtrailConnection(p.request)
await openTrip(p, { tab: 'transports' })
// The addon list and the connection probe are read once, when the app
// loads. One more load now that the connection exists, and the button
// is waited for, so a missing connection fails here and not at step 1.
await p.reload()
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
steps: [
{
target: airtrailButton,
act: async p => {
await airtrailButton(p).click()
await expect(airtrailPicker(p)).toBeVisible()
// The rows come from the AirTrail instance itself, over the LAN.
await expect(flightRow(p, HOME_LEG.flightNumber)).toBeVisible({ timeout: 30_000 })
await expect(flightRow(p, OTHER.flightNumber)).toBeVisible()
await settle(p)
},
},
// The whole picker: During this trip with its ticked rows, Other flights below.
only(airtrailPicker),
// A row under Other flights, unticked, as the text says those are. Not
// clicked: a spring flight imported into an autumn trip would be clamped
// onto day 1 and spoil the result picture.
only(p => flightRow(p, OTHER.flightNumber)),
{
// Joined by default: the tick under the two legs is on before anyone touches it.
prepare: async p => {
await expect(joinToggle(p)).toContainText(`Import as one flight with a layover in ${JOINED.layover}`)
await expect(joinToggle(p).locator('svg.lucide-check')).toBeVisible()
},
target: connectionGroup,
},
{
// The two flights home and nothing else are ticked.
prepare: async p => {
await expect(importCta(p)).toHaveText('Import 2')
},
target: importCta,
act: async p => {
await importCta(p).click()
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
// The toast counts flight ids, so the joined pair reports two.
const toast = p.getByText('2 flights imported', { exact: true })
await expect(toast).toBeVisible({ timeout: 15_000 })
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
// The toast fades after three seconds; the card's picture and the
// result are taken without a half-faded message in the corner.
await expect(toast).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
{
prepare: async p => {
await expect(card(p, JOINED.title).getByText('AirTrail', { exact: true })).toBeVisible()
},
target: p => card(p, JOINED.title),
},
],
cleanup: async p => {
// By every title the import can produce: the joined route, or one
// flight number per card when the join was off.
await deleteByTitle(p, JOINED.title, ...AIRTRAIL_FLIGHTS.map(f => f.flightNumber))
await disconnectAirtrail(p.request)
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTransportFixtures(request)
})
test('every registered transports guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTransportsContext.guides].sort())
})
test('hero: trip-transports', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripTransportsContext.id, p => openTrip(p, { tab: 'transports' }))
})
for (const id of tripTransportsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+252
View File
@@ -0,0 +1,252 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureUsers, seededTrip, ADMIN_USERS } from './fixtures'
import { openTrip, openTripOnDay, modal } from './trip-shared'
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
* the people of the trip (the Share dialog), the columns and the undo. They run
* on the seeded "Autumn in Japan"; what a guide creates (a member, a guest, a
* link) it leaves for the next one or takes away again in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
const inviteBlock = (page: Page) => block(page, /^Invite User/)
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
* column is open and Plan once it is folded, and both names are taken
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
*/
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Share', exact: true }).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(page)
}
const closeModal = async (page: Page): Promise<void> => {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'add-member': {
guide: guide('add-member'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Share', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Select user…' }).click()
// The select's list is rendered outside the dialog.
await p.getByRole('button', { name: 'mara', exact: true }).last().click()
await beat(p, 300)
},
target: inviteBlock,
act: async p => {
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Access\s*\d/)),
],
cleanup: closeModal,
},
'trip-invite-link': {
guide: guide('trip-invite-link'),
start: openShare,
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create invite link' }).click()
await expect(modal(p).getByRole('button', { name: 'Regenerate' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Trip invite link/)),
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
],
cleanup: async p => {
await modal(p).getByRole('button', { name: 'Disable' }).click()
await expect(modal(p).getByRole('button', { name: 'Create invite link' })).toBeVisible({ timeout: 15_000 })
await closeModal(p)
},
},
'add-guest': {
guide: guide('add-guest'),
start: openShare,
steps: [
only(p => block(p, /^Guests/)),
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Guest name'), 'Aunt Lise')
await beat(p, 300)
},
target: p => modal(p).getByPlaceholder('Guest name').locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Add guest' }).click()
await expect(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: closeModal,
},
'public-link': {
guide: guide('public-link'),
start: openShare,
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create link', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create link', exact: true }).click()
await expect(modal(p).getByRole('button', { name: 'Delete link' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => block(p, /^Public Link/),
act: async p => {
await modal(p).getByRole('button', { name: 'Delete link' }).click()
await expect(modal(p).getByRole('button', { name: 'Create link', exact: true })).toBeVisible({ timeout: 15_000 })
},
},
],
cleanup: closeModal,
},
'transfer-ownership': {
guide: guide('transfer-ownership'),
start: openShare,
steps: [
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
],
cleanup: closeModal,
},
'collapse-columns': {
guide: guide('collapse-columns'),
start: p => openTrip(p),
steps: [
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(leftToggle(p)).toHaveAttribute('aria-label', 'Plan')
await settle(p)
},
},
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(p.getByRole('button', { name: 'Export' })).toBeVisible()
await settle(p)
},
},
{
// The divider is invisible until hovered; the hover shows it.
target: p => p.locator('div[style*="col-resize"]').first(),
},
],
},
'undo-change': {
guide: guide('undo-change'),
start: async p => {
const { tripId } = seededTrip()
const created = await p.request.post(`/api/trips/${tripId}/places`, {
data: { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' },
})
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
// A change to undo: the spare place onto day 1, from the places column.
// The day has to stay open for that: the + at the end of a row belongs to
// the open day, and closing the day's details panel closes the day with it.
await openTripOnDay(p, 1)
const row = p.getByRole('option', { name: /^Tsukiji Outer Market/ })
await row.hover()
const add = row.getByRole('button', { name: '+ Day' })
await expect(add, 'the + that puts a place on the open day').toBeVisible({ timeout: 15_000 })
await add.click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Undo' }),
act: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeDisabled({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => x.name === 'Tsukiji Outer Market')) {
await p.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
// The seed's members already have access; the invite needs someone who has not.
await ensureUsers(request, ADMIN_USERS)
})
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
test('every registered trip guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripContext.guides].sort())
})
test('hero: trip', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripContext.id, p => openTrip(p))
})
for (const id of tripContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+587
View File
@@ -0,0 +1,587 @@
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
import { E2E_BASE_URL } from '../../playwright.config'
import { monthName, year } from '../dates'
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Vacay guides, keyed by the ids in `src/help/contexts/vacay.ts`.
* They run in the order the context lists them and build on each other: the
* year the calendar shows gets a few logged days, company holidays and a
* public-holiday calendar as the run goes on, which is what a lived-in plan
* looks like anyway.
*/
const YEAR = year()
/** The month after the picture day's: the first one the planner draws after the current. */
const NEXT_MONTH = monthName(1)
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
const ENTITLEMENT = '28'
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/**
* What clearNotices in `screenshots/shot.ts` clears, less its loop over every
* button whose name contains "next". On this page that loop found the year
* card's "Add next year" and clicked it six times on every visit, so the plan
* grew by six years per guide and the Year card filled up with chips past
* 2100. A tour's own button is called just "Next", so that one is still stepped
* through.
*/
async function clearVacayNotices(page: Page): Promise<void> {
const next = page.getByRole('button', { name: 'Next', exact: true })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
async function openVacay(page: Page): Promise<void> {
await page.goto('/vacay')
await dismissReleaseNotice(page)
await clearVacayNotices(page)
await expect(toolbar(page)).toBeVisible()
await expect(monthCard(page, NEXT_MONTH)).toBeVisible()
}
/** The floating mode toolbar under the grid. */
const toolbar = (page: Page) => page.locator('.vg-card.rounded-full').first()
/** A month card by its (English) heading. */
const monthCard = (page: Page, month: string) => page.locator('.vg-card').filter({ hasText: month }).first()
/** A day cell inside a month card. */
const day = (page: Page, month: string, n: number) => monthCard(page, month).getByRole('button', { name: String(n), exact: true })
/** A sidebar card by its label (the DOM text; CSS uppercases it). */
const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter({ hasText: label }).first()
/** The settings dialog. */
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
/** The invite and share forms: small dialogs portalled to the body. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */
const settingRow = (page: Page, label: string): Locator =>
settings(page).getByText(label, { exact: true }).locator('xpath=../../..')
/** The switch of a settings row, found through the row's label. */
const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first()
/** The form Add calendar opens: colour, label, country, region and its Add button. */
const addCalendarForm = (page: Page): Locator =>
settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..')
/** The Days tile of the Entitlement card while it is being edited. */
const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..')
/** A year chip in the Year card. */
const yearChip = (page: Page, y: number): Locator =>
sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first()
/** A row of a sidebar card (a person, an invite, a share) by the name in it. */
const cardRow = (page: Page, card: string, name: string): Locator =>
sideCard(page, card).locator('.group').filter({ hasText: name }).first()
/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */
const parkPointer = async (page: Page): Promise<void> => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) }
/**
* Back to the top of the page. A step target further down scrolls the page a
* little, and a result picture taken there hides the top row of months under
* the header, unlike the hero and the other results.
*/
async function toTop(page: Page): Promise<void> {
// On a desktop the body is what scrolls (index.css hides the overflow of
// html), so the window's own scroll position is already 0.
await page.evaluate(() => {
for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' })
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
await settle(page)
}
/**
* Turn a settings switch on, and leave it alone when it is on already. Block
* Weekends and Carry Over start out on, and a plain click switched them off,
* so the next picture showed the opposite of what the step had just said. The
* switch has no aria state; its filled track is the only sign it is on.
*/
async function switchOn(page: Page, label: string): Promise<void> {
const sw = toggleRow(page, label)
if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click()
await settle(page)
}
async function openSettings(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Settings' }).click()
await expect(settings(page)).toBeVisible()
await settle(page)
}
async function closeSettings(page: Page): Promise<void> {
await page.keyboard.press('Escape')
await expect(settings(page)).toBeHidden()
// The dialog hands the focus back to the Settings button, and after a key
// press that draws a thick focus outline, which in the next picture reads
// like a step ring.
await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await settle(page)
}
/**
* Click Add calendar and fill the form the way the step describes, short of
* adding it, so the picture shows the fields filled in.
*/
async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise<void> {
await addButton.click()
await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label)
await pick(page, settings(page).getByRole('button', { name: 'Select country' }), 'Germany')
// The region list loads after the country is picked; give it a moment to show up.
const region = settings(page).getByRole('button', { name: 'Select region (required)' })
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin')
await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
}
async function submitCalendarForm(page: Page): Promise<void> {
await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click()
// The form closes once the calendar is saved and turns into its row.
await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 })
await settle(page)
}
/**
* "Close Settings", the last step of the holiday guides. The ring goes round
* the head band rather than the close button alone: the 960 px dialog is as
* wide as the smallest frame, so a frame centred on the button leaves out the
* band's title and the calendar the guide just added, and the band is still
* what the step names. The pointer rests on the button, so its Close tooltip
* marks where to click inside the ring.
*/
const settingsCloseStep: StepAction = {
target: p => settings(p).locator('header').first(),
hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() },
act: closeSettings,
}
// ── A calendar shared with the admin ──────────────────────────────────────────
/**
* The seeded member whose calendar the share-calendar guide shows as shared
* with the admin. Not jonas: the admin shares with jonas in the same guide,
* and two rows named jonas would hide which one is incoming.
*/
const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' }
/**
* A request context acting as the member. Its own context with a bearer
* token: a login on the page's context would put the member's session cookie
* on the admin's requests.
*/
async function asMember(member: typeof SHARER): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined })
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } })
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined
await anon.dispose()
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`)
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
})
}
/** The member shares her calendar with the admin, as she would from her own Vacay. */
async function shareWithAdmin(page: Page): Promise<void> {
const me = await page.request.get('/api/auth/me')
const adminId = ((await me.json()) as { user: { id: number } }).user.id
const member = await asMember(SHARER)
try {
const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } })
// A retried guide finds the share from its first attempt still there.
if (!res.ok() && !(await res.text()).includes('Already shared')) {
throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`)
}
} finally {
await member.dispose()
}
}
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
async function unshareWithAdmin(page: Page): Promise<void> {
const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] }
for (const s of shares.incoming.filter(x => x.username === SHARER.username)) {
const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`)
if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`)
}
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
notifications: { id: number; title_key: string; sender_username: string | null }[]
}
for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) {
await page.request.delete(`/api/notifications/in-app/${n.id}`)
}
}
/**
* Pick an option in a CustomSelect: open it, filter, click the match inside
* the open menu. Scoped to the menu on purpose: a calendar row already set to
* "Germany" renders a trigger button of that very name, which a page-wide
* lookup would hit first.
*/
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
const menu = search.locator('xpath=../..')
await menu.getByRole('button', { name: text, exact: true }).first().click()
}
const SCRIPTS: Record<string, GuideScript> = {
'log-day': {
guide: guide('log-day'),
start: openVacay,
steps: [
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, 14),
act: async p => { await day(p, NEXT_MONTH, 14).click(); await beat(p, 600) },
},
{
target: p => day(p, NEXT_MONTH, 14),
act: async p => {
await day(p, NEXT_MONTH, 14).click()
await beat(p, 600)
// Leave the day logged for the after-picture and the guides that follow.
await day(p, NEXT_MONTH, 14).click()
await toTop(p)
},
},
],
},
'half-day': {
guide: guide('half-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
{
target: p => day(p, NEXT_MONTH, 15),
act: async p => { await day(p, NEXT_MONTH, 15).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) },
},
],
},
'comp-day': {
guide: guide('comp-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Comp / Flex' }),
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
},
{
target: p => day(p, NEXT_MONTH, 16),
act: async p => {
await day(p, NEXT_MONTH, 16).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
await toTop(p)
},
},
],
},
entitlement: {
guide: guide('entitlement'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first(),
act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() },
},
{
// The seed's entitlement is 30, so typing 30 changed nothing and the
// result showed the Left of before. A different number is typed first,
// so the picture shows it in the field and the result recalculates Left.
prepare: async p => {
const input = sideCard(p, 'Entitlement').locator('input')
await input.fill('')
await typeInto(p, input, ENTITLEMENT)
},
// The whole tile, so the ring and its number sit round the Days label
// and the field rather than on the label.
target: daysTile,
act: async p => {
await sideCard(p, 'Entitlement').locator('input').press('Enter')
await expect(sideCard(p, 'Entitlement').locator('input')).toHaveCount(0)
await toTop(p)
},
},
],
},
years: {
guide: guide('years'),
start: openVacay,
steps: [
{
target: p => p.getByTitle('Add next year'),
act: async p => {
await p.getByTitle('Add next year').click()
await expect(sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) })).toBeVisible()
await settle(p)
},
},
{
target: p => sideCard(p, 'Year').locator('.grid'),
act: async p => {
// Over to the new year and back. Staying put would leave the Entitlement
// card on the numbers of the year just added, which Add next year loads
// under the heading of the year still selected; switching reloads them.
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().click()
await settle(p)
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click()
await settle(p)
},
},
{
// The chip with its minus showing, rather than the minus alone: the step
// says to hover the chip, and a ring round the tiny minus put its number
// on top of the year it belongs to.
prepare: async p => { await yearChip(p, YEAR + 1).hover() },
target: p => yearChip(p, YEAR + 1),
// With the ring on the chip, this is what still fails the guide when the minus is gone.
act: async p => { await expect(yearChip(p, YEAR + 1).getByRole('button', { name: 'Remove year' })).toBeVisible() },
},
],
cleanup: async p => {
// The extra year was for the pictures only; the guides that follow expect the seed's single year.
const res = await p.request.delete(`/api/addons/vacay/years/${YEAR + 1}`)
if (!res.ok()) throw new Error(`could not remove year ${YEAR + 1}: ${res.status()}`)
},
},
'company-holidays': {
guide: guide('company-holidays'),
start: openVacay,
steps: [
{
// On by default, so the step only shows where the switch lives. The ring
// takes the whole row, name and switch, which is what "check that it is
// on" reads off, and it pulls the frame over the dialog instead of the
// page beside it.
prepare: openSettings,
target: p => settingRow(p, 'Company Holidays'),
act: closeSettings,
},
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await beat(p, 400)
await day(p, 'December', 31).click()
await settle(p)
// Back to logging vacation, so the next guide starts where a reader would.
await toolbar(p).getByRole('button').first().click()
await toTop(p)
},
},
],
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
steps: [
{
// The whole row, as in company-holidays step 1: name and switch together.
prepare: openSettings,
target: p => settingRow(p, 'Public Holidays'),
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
},
{
// The step is about the country and the region, so the picture shows
// the form filled in rather than the button that opens it empty.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).first(), 'Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
'school-holidays': {
guide: guide('school-holidays'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settingRow(p, 'School Holidays'),
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
},
{
// The last Add calendar: the public-holiday one above it is the first.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).last(), 'Schools Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
weekends: {
guide: guide('weekends'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Block Weekends'),
act: async p => { await switchOn(p, 'Block Weekends') },
},
{
target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'),
act: closeSettings,
},
],
},
'leave-year': {
guide: guide('leave-year'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'),
},
{
// The step names all three choices, so the ring goes round the row that holds them.
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first().locator('xpath=..'),
act: closeSettings,
},
],
},
'carry-over': {
guide: guide('carry-over'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Carry Over'),
act: async p => { await switchOn(p, 'Carry Over'); await closeSettings(p) },
},
],
},
invite: {
guide: guide('invite'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Persons').getByRole('button').first(),
act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) },
},
{
// Picked before the picture, so it shows mira chosen and Send Invite
// ready, which is the button the step ends on; the dialog closes on send,
// so no later picture shows the choice.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)')
await expect(dialog(p).getByRole('button', { name: 'Send Invite' })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Send Invite' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Send Invite' }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole row, with the pending chip the step is about. The pointer
// stays off it, or the row's Cancel button shows as well.
target: p => cardRow(p, 'Persons', 'mira'),
hover: parkPointer,
},
],
},
'share-calendar': {
guide: guide('share-calendar'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Shared Calendars').getByTitle('Share calendar'),
act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) },
},
{
// As in the invite guide: jonas picked and Share ready in the picture.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas')
await expect(dialog(p).getByRole('button', { name: 'Share', exact: true })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Share', exact: true }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The step also names calendars shared with you, and the seed has none,
// so mira shares hers with the admin first. The page learns of it over
// the socket; a reload is the fallback should that message not come.
prepare: async p => {
await shareWithAdmin(p)
const incoming = cardRow(p, 'Shared Calendars', SHARER.username)
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
await expect(incoming).toBeVisible()
},
target: p => sideCard(p, 'Shared Calendars'),
// On the row the admin shares, so its Stop sharing button shows.
hover: async p => { await cardRow(p, 'Shared Calendars', 'jonas').hover() },
},
],
cleanup: unshareWithAdmin,
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test('every registered vacay guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(vacayContext.guides.slice().sort())
})
test('hero: vacay', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, vacayContext.id, openVacay)
})
for (const id of vacayContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+47
View File
@@ -0,0 +1,47 @@
import type { Page } from '@playwright/test'
/**
* Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly
* seeded user on first load and cover the dashboard — the backdrop swallows
* clicks aimed at anything underneath, `.add-trip-card` included.
*
* The host renders asynchronously (after the notices fetch), so wait for the
* notice dialog before deciding there is nothing to clear. Every lookup is
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
* (carousel arrows) and satisfy the wait before the modal even mounts.
*
* A notice closes one of three ways depending on its shape:
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
* X button (`aria-label="Dismiss"`), shown on the last page.
* - CTA-less notices show an "OK" button that pages forward and dismisses on
* the last page.
* - The release notes (ReleaseNoticeModal), shown again after every update,
* close through their X (`aria-label="Close"`).
* Multi-page notices are paged through via the pager's Next button first.
* Dismissal is persisted server-side per user, so clearing once keeps it
* cleared for every later spec in the run (shared test DB).
*/
export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000): Promise<void> {
const dialog = page.getByRole('dialog').first()
await dialog.waitFor({ state: 'visible', timeout: appearTimeoutMs }).catch(() => {})
// Clear up to a handful of queued notices.
for (let notice = 0; notice < 4 && (await dialog.isVisible().catch(() => false)); notice++) {
const next = dialog.getByRole('button', { name: /next/i })
for (let i = 0; i < 8 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click()
}
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
const close = dialog.getByRole('button', { name: 'Close', exact: true })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else if (await close.isVisible().catch(() => false)) await close.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
}
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
+95
View File
@@ -0,0 +1,95 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
//
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. What
// turned the swipe into a drag was the drag-drop-touch polyfill, confined to hybrid
// laptops since. #1616 then gave tablets their drag back on purpose, through the
// long-press bridge ([data-touch-drag]): a row may be draggable, but only a press that
// is held becomes a drag, so a swipe still scrolls. Only a real device context proves
// that; a jsdom unit test cannot express "coarse pointer at 834px".
//
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
// is WebKit underneath, which is why the reporter saw this in all three they tried.
test.use({ ...devices['iPad Pro 11'] })
test('#1432 iPad: the places list scrolls under a swipe', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
await expect(createBtn).toBeVisible()
const title = `iPad 1432 ${Date.now()}`
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
await createBtn.click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
// Seed enough places for the list to overflow and actually need scrolling.
for (let i = 1; i <= 25; i++) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
})
expect(res.ok(), `seed place ${i}`).toBeTruthy()
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// An iPad is inside the 768-1023px band, where only one side panel is open at a
// time so the map keeps a usable width (#2247). The places list is one tap away.
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
// "Plan" button of its own.
await page.locator('button[aria-label="Places"]').click()
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
// width. If either is wrong, everything below proves nothing.
const env = await page.evaluate(() => ({
coarse: window.matchMedia('(pointer: coarse)').matches,
width: window.innerWidth,
}))
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
// 1. A swipe must not become a drag. What turned it into one in #1432 was the
// drag-drop-touch polyfill; it belongs to hybrid laptops only now
// (utils/touchDragPolyfill.ts), so a tablet must never load it. A row may be
// draggable, but only behind the long-press bridge (#1616).
const polyfillLoaded = await page.evaluate(() =>
performance.getEntriesByType('resource').some(e => /drag-?drop-?touch/i.test(e.name)))
expect(polyfillLoaded, 'the drag-drop-touch polyfill stays off a tablet').toBe(false)
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toBeVisible()
const swipeSafe = await row.evaluate(el => el.getAttribute('draggable') !== 'true' || !!el.closest('[data-touch-drag]'))
expect(swipeSafe, 'a draggable row sits behind the long-press bridge').toBe(true)
// 2. The list must scroll, and no drop-to-import overlay may appear.
// The list's scroll container carries trek-stagger among its other classes. Taken
// from the row above: the first draggable on the page can be another panel's.
const scroller = row.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " trek-stagger ")]')
const before = await scroller.evaluate(el => el.scrollTop)
const box = (await scroller.boundingBox())!
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
await scroller.evaluate(el => el.scrollBy(0, 200))
const after = await scroller.evaluate(el => el.scrollTop)
expect(after, 'places list scrolled').toBeGreaterThan(before)
await expect(page.getByText('Drop to import')).toHaveCount(0)
// 3. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
// The top tab bar has a button named Plan as well; the way back is the panel's
// own tab, which is accented while its panel is closed.
await expect(page.locator('button[aria-label="Plan"].bg-accent')).toBeVisible()
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, devices } from '@playwright/test'
import type { Page } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone coverage for #2051: the day details area steps days under a horizontal
// swipe, so the trip is navigable one-handed instead of only from the chip rail
// pinned to the top of the screen.
//
// jsdom can drive the handlers but not the browser around them: whether the
// gesture survives real touch dispatch, whether the browser hands the vertical
// axis to its own scroller first, and whether a swipe at the first day stays put
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
// events are the ones a finger produces — not synthesised TouchEvent objects.
//
// Chromium, because CDP is where real touch injection lives. The file-level
// device sets a phone viewport with a coarse pointer, which is what puts the
// mobile shell on screen at all.
test.use({ ...devices['Pixel 7'] })
/** One finger, dispatched through the browser's own input pipeline. */
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
for (let i = 1; i <= steps; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{
x: from.x + ((to.x - from.x) * i) / steps,
y: from.y + ((to.y - from.y) * i) / steps,
}],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
// The out leg is 150ms and the in leg 220ms.
await page.waitForTimeout(500)
}
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
// and everything below would be proving nothing.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
const stamp = Date.now()
const created = await page.request.post('/api/trips', {
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
})
expect(created.ok(), 'seed trip').toBeTruthy()
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
await page.goto(`/trips/${tripId}`)
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
const chips = page.locator('button[aria-current], button[aria-current="true"]')
const rail = activeChip(page).locator('xpath=..')
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
expect(await chips.count()).toBeGreaterThan(0)
// The swipe zone: below the chip rail, above the dock, across the middle.
const mid = Math.round(env.width / 2)
const y = 460
const day1 = (await activeChip(page).textContent())!.trim()
// 1. A swipe left steps forward.
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
const day2 = (await activeChip(page).textContent())!.trim()
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
// 2. A swipe right steps back to where it started.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 3. The first day has nowhere to go back to — it must stay put, and the
// browser must not have been handed a page-back either.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
expect(page.url()).toContain(`/trips/${tripId}`)
// 4. A vertical drag belongs to the card's own scroller, never to the day.
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 5. And the rail itself still works, so the gesture added a path rather than
// replacing one.
await rail.locator('button').nth(2).click()
await expect(activeChip(page)).not.toHaveText(day1)
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
// at opacity:0 with no way to reveal them.
//
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
// note carried an invisible, fully clickable target that silently reordered the trip.
// These cases pin both halves — hidden means non-interactive, hover means visible.
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
const title = `Reorder ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
const dayId = (daysRes.days ?? daysRes)[0].id
for (const name of ['Alpha', 'Beta']) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name, lat: 48.85, lng: 2.35 },
})
const body = await res.json()
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
data: { place_id: body.place?.id ?? body.id },
})
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
await expect(row).toBeVisible({ timeout: 20_000 })
const arrows = row.locator('.reorder-buttons')
// Unhovered: invisible AND inert — a click there must not land on the button.
const idle = await arrows.evaluate(el => {
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
})
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
// Hovered: revealed and clickable.
await row.hover()
await expect(arrows).toHaveCSS('opacity', '1')
await expect(arrows).toHaveCSS('pointer-events', 'auto')
})
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from './shot'
/**
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
* this project otherwise inherits, so these render as a logged-out visitor sees
* them — which is the entire point of the login and registration pages.
*/
test.use({ storageState: undefined })
test('login page', async ({ page, shot }) => {
await page.goto('/login')
await expect(page.locator('input[type="email"]')).toBeVisible()
await shot.page_('Login')
})
test('registration page', async ({ page, shot }) => {
await page.goto('/register')
await page.waitForTimeout(500)
await shot.page_('Registration')
})
test('forgot password', async ({ page, shot }) => {
await page.goto('/forgot-password')
await page.waitForTimeout(500)
await shot.page_('PasswordReset')
})
+68
View File
@@ -0,0 +1,68 @@
import { test, clearNotices, expect, seed } from './shot'
import { PICTURE_DAY } from '../help/guide'
import type { Page, Locator } from '@playwright/test'
/**
* Collab surfaces, one capture each.
*
* Until now a single Collab.png illustrated four different wiki pages — chat,
* notes, polls and the What's Next widget — so at most one of them showed the
* feature its page described.
*
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
* So each capture targets its own card element rather than clicking a tab.
*/
/**
* The panel card containing a given piece of seeded content: see cardClass in
* CollabPanel.tsx.
*
* Matching on content rather than the panel heading is deliberate: the headings
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
* those same words also appear in the mobile tab bar, so a heading match is both
* wrong-cased and ambiguous.
*/
function card(page: Page, contains: string): Locator {
return page
.locator('div.bg-surface-secondary.rounded-2xl.min-h-0')
.filter({ hasText: contains })
.last()
}
test.beforeEach(async ({ page }) => {
// What's Next lists only what is still ahead, and shots.setup.ts timed its stop
// against the picture day's morning, the instant the help pictures are taken at.
await page.clock.setFixedTime(PICTURE_DAY)
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
await page.waitForTimeout(1200)
})
test('collab chat', async ({ page, shot }) => {
// Seeded as three different people; a single-voice log would misrepresent it.
// The chat auto-scrolls to the newest message, so assert on the last line of
// the seeded conversation rather than the first — the first is off-screen.
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
await shot.element('CollabChat', card(page, 'kaiseki'))
})
test('collab notes', async ({ page, shot }) => {
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
await shot.element('CollabNotes', card(page, 'Rail passes'))
})
test('collab polls', async ({ page, shot }) => {
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
await shot.element('CollabPolls', card(page, 'free for Nara'))
})
test("what's next widget", async ({ page, shot }) => {
await shot.element('WhatsNext', card(page, "What's Next"))
})
test('collab overview', async ({ page, shot }) => {
await shot.page_('Collab')
})
+83
View File
@@ -0,0 +1,83 @@
import { test, clearNotices, expect, seed } from './shot'
/**
* Detail pages and the surfaces that need a couple of clicks to reach.
*
* Each capture asserts something specific to the surface before shooting, so a
* navigation that quietly lands on a fallback (or an addon that is off) fails
* the run instead of producing a screenshot of the wrong screen.
*/
test('collection detail', async ({ page, shot }) => {
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
await page.goto(`/collections/${seed.collectionId}`)
await clearNotices(page)
await shot.page_('CollectionDetail')
})
test('journey detail', async ({ page, shot }) => {
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
await page.goto(`/journey/${seed.journeyId}`)
await clearNotices(page)
await shot.page_('JourneyDetail')
})
test('mcp access — admin', async ({ page, shot }) => {
await page.goto('/admin')
await clearNotices(page)
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('MCPAccess')
})
test('two-factor setup', async ({ page, shot }) => {
await page.goto('/settings')
await clearNotices(page)
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
await page.waitForTimeout(600)
// The enrolment flow is behind a button whose label varies with state; match
// loosely and fall back to capturing the tab itself.
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
if (await enable.isVisible().catch(() => false)) {
await enable.click()
await page.waitForTimeout(900)
}
await shot.page_('2FA')
})
/**
* Settle-up.
*
* WARNING for anyone extending this file: the "Settle up" button in the Costs
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
* of this test clicked it, which zeroed every balance and left the capture
* showing "Everyone's square". Because all screenshot specs share one database
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
* same run.
*
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
* instead — same surface, no side effect — and close it again.
*/
test('costs — record a settle-up payment', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
await page.waitForTimeout(800)
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
await addPayment.click()
await page.waitForTimeout(700)
const modal = page.locator('.trek-modal-backdrop > div').first()
await expect(modal).toBeVisible()
await shot.element('CostsSettleUp', modal)
})
test('trip files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await clearNotices(page)
await expect(page).toHaveURL(/files/)
await shot.page_('Documents')
})
+37
View File
@@ -0,0 +1,37 @@
import { test, clearNotices, expect, seed } from './shot'
/**
* Modals and dialogs.
*
* Captured as element screenshots (not full page) so the wiki gets the dialog
* itself rather than a dimmed backdrop with a small box in the middle. Each one
* asserts the dialog is actually open first — a missed click would otherwise
* silently produce a screenshot of the page behind it.
*/
/**
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
* role="dialog" nor aria-modal, so there is no accessible role to query — the
* backdrop class is the only stable hook. Target its child, which is the panel
* itself, so the capture excludes the dimmed backdrop.
*/
function dialog(page: import('@playwright/test').Page) {
return page.locator('.trek-modal-backdrop > div').first()
}
test('create trip modal — with the new currency field', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await page.getByRole('button', { name: /new trip/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TripCreate', dialog(page))
})
test('share dialog', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /share/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('Share', dialog(page))
})
+73
View File
@@ -0,0 +1,73 @@
import { test, clearNotices, seed } from './shot'
/**
* Top-level navigable surfaces. One capture per route; anything that needs a
* dialog opened or a tab clicked lives in its own spec so a failure there
* cannot take these down with it.
*
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
* for which wiki page consumes which file.
*/
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
await clearNotices(page)
})
test('dashboard', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await shot.page_('DashboardWidgets')
})
test('trip planner', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
// The seeded trip is running, so the plan opens on today, which is empty; the
// picture shows the first days, where the plan has places, a note and a flight.
// On the day's number: the middle of the header is the booked night's pill.
await page.locator('.dp-day-header').first().click({ position: { x: 22, y: 20 } })
await page.waitForTimeout(1500)
await page.locator('.dp-day-header').first().evaluate(el => el.closest('.overflow-y-auto')?.scrollTo({ top: 0 }))
await page.waitForTimeout(600)
await shot.page_('TripPlanner')
})
test('atlas', async ({ page, shot }) => {
await page.goto('/atlas')
await shot.page_('Atlas')
})
test('vacay', async ({ page, shot }) => {
await page.goto('/vacay')
await shot.page_('Vacay')
})
test('collections', async ({ page, shot }) => {
await page.goto('/collections')
await shot.page_('Collections')
})
test('journey', async ({ page, shot }) => {
await page.goto('/journey')
await shot.page_('Journey')
})
test('notifications inbox', async ({ page, shot }) => {
await page.goto('/notifications')
await shot.page_('NotificationsInbox')
})
test('in-app help', async ({ page, shot }) => {
await page.goto('/help')
await shot.page_('HelpInApp')
})
test('files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /^Files/ }).first().click()
await page.waitForTimeout(1200)
await shot.page_('Files')
})
+234
View File
@@ -0,0 +1,234 @@
import type { Page } from '@playwright/test'
import { test, clearNotices, expect, seed } from './shot'
import { TOKYO_HOTEL } from '../help/fixtures'
/**
* The Plan tab and the dialogs of the trip page, as the wiki pages for the day
* plan, places, bookings, transports and costs show them. Every picture drives
* the real UI by its roles and names, so a renamed control fails here instead
* of quietly photographing the wrong thing.
*/
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.waitForTimeout(800)
})
const dialog = (page: Page) => page.getByRole('dialog').last()
const firstDayCard = (page: Page) => page.locator('.dp-day-header').first().locator('xpath=..')
async function openTab(page: Page, label: string) {
// A tab can carry a longer name for screen readers than its label ("Book" for the bookings).
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
await page.waitForTimeout(800)
}
// On the day's number at the left edge: the middle of the header is the booked
// night's pill once the day has a stay, and a click there opens the hotel instead.
async function selectDay(page: Page, index: number) {
await page.locator('.dp-day-header').nth(index).click({ position: { x: 22, y: 20 } })
await page.waitForTimeout(900)
}
const selectFirstDay = (page: Page) => selectDay(page, 0)
/** Out of the way, so no tooltip of the button just clicked stays in the picture. */
const parkPointer = (page: Page) => page.mouse.move(1, 1)
/** A row of the places column, by the place's name. */
const placeOption = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
test('plan: a day card', async ({ page, shot }) => {
await shot.element('PlanDayCard', firstDayCard(page))
})
test('plan: the day "+" menu', async ({ page, shot }) => {
await selectFirstDay(page)
await firstDayCard(page).locator('button[aria-label="Add to day"]').click()
await expect(page.getByRole('button', { name: 'Add Note' })).toBeVisible()
await shot.page_('PlanDayAddMenu')
})
test('plan: the route bar', async ({ page, shot }) => {
await selectFirstDay(page)
const bar = page.locator('[data-dp="route-tools"]').first()
await expect(bar).toBeVisible()
await shot.element('PlanRouteBar', bar)
})
test('plan: the places column', async ({ page, shot }) => {
const column = page.getByPlaceholder('Search').first().locator('xpath=ancestor::div[contains(@class,"flex-col")][2]')
await shot.element('PlacesColumn', column)
})
test('plan: the place inspector', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1200)
await shot.page_('PlaceInspector')
})
test('plan: the save to list dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
await page.getByRole('button', { name: /^Save to Collection/ }).first().click()
await expect(dialog(page)).toBeVisible()
await expect(dialog(page).getByText('Kyoto shortlist')).toBeVisible()
await page.waitForTimeout(500)
await shot.element('SaveToList', dialog(page))
})
test('plan: the edit place dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
await page.getByRole('button', { name: 'Edit', exact: true }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(2500)
await shot.element('PlaceForm', dialog(page))
})
test('plan: the day details', async ({ page, shot }) => {
await selectFirstDay(page)
await shot.page_('DayDetails')
})
test('plan: the stay editor', async ({ page, shot }) => {
// Day 9 has no stay yet, so its details offer Add accommodation.
await selectDay(page, 8)
await page.getByRole('button', { name: 'Add accommodation' }).first().click()
await expect(dialog(page)).toBeVisible()
// A hotel picked, so the head band carries its name.
await dialog(page).getByText('Hotel Granvia Kyoto', { exact: true }).click()
await page.waitForTimeout(500)
await shot.element('StayEditor', dialog(page))
})
test('plan: the map hover card', async ({ page, shot }) => {
// With no day open every stop is folded into a cluster; the open day's stops are
// pins. Its details panel stays: closing it would close the day as well.
await selectFirstDay(page)
// A place pin, not a cluster bubble or an explore result (only those carry a
// name). The columns float over the map's edges and pins stack, so the first
// one in the DOM is often one the pointer cannot reach.
const pins = page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
await expect(pins.first()).toBeAttached()
const total = await pins.count()
for (let i = 0; i < total; i++) {
if (await pins.nth(i).hover({ trial: true, timeout: 1500 }).then(() => true, () => false)) {
await pins.nth(i).hover()
break
}
}
const card = page.getByTestId('tooltip')
await expect(card).toBeVisible()
await page.waitForTimeout(600)
// The card and the pin it belongs to with some map around them, not the whole planner.
const box = (await card.boundingBox())!
const width = 760
const height = 440
const x = Math.max(0, Math.min(1440 - width, box.x + box.width / 2 - width / 2))
const y = Math.max(0, Math.min(900 - height, box.y + box.height / 2 - height / 2))
await shot.region('MapHoverCard', { x, y, width, height })
})
test('plan: the export dialog', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('ExportDialog', dialog(page))
})
test('plan: the subscribe to calendar dialog', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await dialog(page).getByRole('button', { name: /^Subscribe to calendar/ }).click()
const subscribe = page.getByRole('dialog').filter({ hasText: 'Subscribe to calendar' }).last()
await subscribe.getByRole('button', { name: 'Enable calendar subscription' }).click()
// Enabled, the dialog shows the ways to subscribe and its Regenerate and Turn off.
await expect(subscribe.getByRole('button', { name: 'Turn off' })).toBeVisible()
await shot.element('IcsSubscribe', subscribe)
await subscribe.getByRole('button', { name: 'Turn off' }).click()
})
test('plan: the PDF preview', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await dialog(page).getByRole('button', { name: /^PDF/ }).click()
const preview = page.locator('#pdf-preview-overlay > div')
await expect(preview).toBeVisible({ timeout: 30_000 })
await parkPointer(page)
// The document is a srcdoc frame; its photos and the route map load after it.
await page.waitForTimeout(4000)
await shot.element('PDFPreview', preview)
await page.locator('#pdf-close-btn').click()
})
// The Bookings tab holds a booking of every kind here (shots.setup.ts), so the
// views are shown on it; each test puts the view back to the cards.
async function bookingsView(page: Page, view: 'Cards' | 'List' | 'Timeline') {
await openTab(page, 'Book')
await page.getByRole('button', { name: view, exact: true }).first().click()
await parkPointer(page)
await page.waitForTimeout(800)
}
test('bookings: list view', async ({ page, shot }) => {
await bookingsView(page, 'List')
await shot.page_('BookingsList')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('bookings: timeline view', async ({ page, shot }) => {
await bookingsView(page, 'Timeline')
await shot.page_('BookingsTimeline')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('bookings: the detail popup and the editor', async ({ page, shot }) => {
await bookingsView(page, 'Cards')
await page.locator('article').filter({ hasText: TOKYO_HOTEL }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(600)
await shot.element('BookingDetail', dialog(page))
await dialog(page).getByRole('button', { name: 'Edit', exact: true }).click()
await page.waitForTimeout(800)
await shot.element('BookingEditor', dialog(page))
})
test('transports: timeline view', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: 'Timeline', exact: true }).first().click()
await parkPointer(page)
await page.waitForTimeout(800)
await shot.page_('TransportsTimeline')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('transports: the detail popup', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
await page.locator('article').filter({ hasText: 'LH716' }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(600)
await shot.element('TransportDetail', dialog(page))
})
test('transports: the add transport dialog', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: /^(Add transport|Transport)$/ }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TransportEditor', dialog(page))
})
test('costs: the add expense dialog', async ({ page, shot }) => {
await openTab(page, 'Costs')
await page.getByRole('button', { name: 'Add expense' }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('ExpenseDialog', dialog(page))
})
test('costs: the table view', async ({ page, shot }) => {
await openTab(page, 'Costs')
await page.getByRole('button', { name: 'Table', exact: true }).click()
await expect(page.getByRole('table')).toBeVisible()
await parkPointer(page)
await shot.page_('CostsTable')
// Back to the list for every other picture of the tab.
await page.getByRole('button', { name: 'List', exact: true }).click()
})
+52
View File
@@ -0,0 +1,52 @@
import { test, clearNotices, seed } from './shot'
/**
* Trip-planner tabs and dialogs.
*
* Tabs are reached by their visible label rather than a test id, deliberately:
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
* instead of silently capturing the wrong panel — which is exactly how the
* current wiki ended up with screenshots the text contradicts.
*/
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
})
async function openTab(page: import('@playwright/test').Page, label: string) {
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
await page.waitForTimeout(700)
}
test('costs panel', async ({ page, shot }) => {
await openTab(page, 'Costs')
await shot.page_('Costs')
})
test('lists — packing', async ({ page, shot }) => {
await openTab(page, 'Lists')
await shot.page_('PackingList')
})
test('lists: to-do with a task open', async ({ page, shot }) => {
await openTab(page, 'Lists')
await page.getByRole('button', { name: /^To-Do/ }).first().click()
await page.waitForTimeout(600)
await page.getByText('Activate JR Pass', { exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('Todos')
await page.getByRole('button', { name: /^Packing List/ }).first().click()
})
test('transports', async ({ page, shot }) => {
await openTab(page, 'Transports')
await shot.page_('Transports')
})
test('bookings', async ({ page, shot }) => {
await openTab(page, 'Book')
await shot.page_('Bookings')
})
+59
View File
@@ -0,0 +1,59 @@
// Moves captured screenshots from the staging directory into wiki/assets/,
// downscaling and re-encoding on the way.
//
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
// of raw pixels. The wiki renders images at roughly 800–1000px, so shipping
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
// on HiDPI displays at the size it is actually shown.
//
// Usage: node e2e/screenshots/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
const MAX_WIDTH = 1600
const dry = process.argv.includes('--dry')
mkdirSync(DEST, { recursive: true })
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
if (!files.length) {
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
process.exit(1)
}
let before = 0
let after = 0
for (const file of files.sort()) {
const src = path.join(SRC, file)
const dest = path.join(DEST, file)
const srcBytes = statSync(src).size
before += srcBytes
const img = sharp(src)
const { width } = await img.metadata()
const pipeline = sharp(src)
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
.png({ compressionLevel: 9, effort: 10 })
const buf = await pipeline.toBuffer()
after += buf.length
const pct = Math.round((1 - buf.length / srcBytes) * 100)
console.log(
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)} → ${kb(buf.length).padStart(8)} (-${pct}%)`,
)
if (!dry) await sharp(buf).toFile(dest)
}
console.log(`\n${files.length} files: ${kb(before)} → ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
function kb(bytes) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
}
+37
View File
@@ -0,0 +1,37 @@
import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
* runs. Its own Playwright project so it executes exactly once, after `setup`
* (which produces the authenticated storageState) and before `screenshots`.
*
* The resulting ids are written to disk because Playwright projects do not
* share memory — the capture specs read them back.
*/
setup('seed the demo trip', async ({ page, playwright }) => {
// page.request carries the storageState cookie, so this is authenticated as
// the admin. The factory hands the seeder throwaway contexts for the other
// members — see the comment in seed.ts on why they must not share one.
const result = await seedDemoData(page.request, token =>
playwright.request.newContext({
baseURL: E2E_BASE_URL,
// MUST be explicit: newContext otherwise picks up the project's
// storageState, i.e. the admin's trek_session cookie — and the server
// reads the cookie BEFORE the Authorization header
// (server/src/middleware/auth.ts:9), so every "member" write would be
// recorded as the admin while still returning 200.
storageState: undefined,
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
}),
)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
mkdirSync(path.join(process.cwd(), 'e2e', '.tmp'), { recursive: true })
writeFileSync(path.join(process.cwd(), E2E_SEED_FILE), JSON.stringify(result, null, 2))
})
+354
View File
@@ -0,0 +1,354 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
import { at, day, ordinal, short } from '../dates'
/**
* Demo data for the documentation screenshots.
*
* Seeded over the REST API (not the DB) so it exercises the same paths a real
* user would and stays honest about validation. The session cookie comes from
* the storageState that auth.setup.ts writes, so `page.request` is already
* authenticated as the seeded admin.
*
* Design notes that matter for the screenshots:
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
* currency rework (per-trip currency, frozen FX rates, foreign-currency
* settle-up) — the reader would see nothing new.
* - Two extra members exist so splits, avatars and sharing tiers render with
* real names instead of a lonely single-user state.
* - Dates are relative to the run day (e2e/dates.ts): the trip ends today, so
* the running surfaces (boarding pass, What's Next, forecast, reminders) have
* something to show whenever the seed runs.
*/
const TRIP = {
title: 'Autumn in Japan',
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
start_date: day(-9),
end_date: day(0),
currency: 'JPY',
reminder_days: 3,
}
const MEMBERS = [
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
]
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
]
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: day(-6), note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: day(-9), note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: day(-4) },
]
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
]
/** Chat lines the collab guides look for by their text. */
export const SEED_CHAT = {
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
}
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
]
export interface SeedResult {
tripId: number
memberIds: number[]
dayIds: number[]
placeIds: number[]
collectionId?: number
journeyId?: number
}
/** Throws with the response body on failure — a silent 4xx here would produce
* a screenshot of an empty screen, which is worse than a loud crash. */
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
path: string, body?: unknown): Promise<T> {
const res = await api[method](path, body === undefined ? {} : { data: body })
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path} → ${res.status()}\n${await res.text()}`)
}
return (await res.json()) as T
}
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
export async function seedDemoData(
api: APIRequestContext,
newContext?: ContextFactory,
): Promise<SeedResult> {
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
// every later call to those modules 403s until these are flipped.
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
}
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
// when a default does. They match the current defaults (ba3733da made
// celsius/metric/24h consistent across the store and the settings UI) —
// stating them here keeps the captures reproducible either way.
await call(api, 'post', '/api/settings/bulk', {
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
})
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
const memberIds: number[] = []
for (const m of MEMBERS) {
const res = await api.post('/api/admin/users', { data: m })
if (res.ok()) {
const { user } = (await res.json()) as { user: { id: number } }
memberIds.push(user.id)
} else if (res.status() !== 409) {
throw new Error(`create user ${m.username} → ${res.status()}\n${await res.text()}`)
}
}
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
}
// 4. Days are auto-generated by trip creation — read them back for assignment.
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
api, 'get', `/api/trips/${tripId}/days`)
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const placeIds: number[] = []
for (const p of PLACES) {
const { day, ...payload } = p
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
}
}
// 6. A day note, so the itinerary shows more than places.
if (dayIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
time: '08:15', icon: 'train',
}).catch(() => {})
}
// 7. Costs. Split across everyone so the settle-up view has real balances.
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
for (const e of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: 1, amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
if (memberIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
}).catch(() => {})
}
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => {})
}
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
// are silently dropped by the server, leaving a booking with no route.
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
title: 'LH716 FRA → HND',
type: 'flight',
reservation_time: at(-9, '13:05'),
reservation_end_time: at(-8, '08:25'),
confirmation_number: 'X7K2QP',
status: 'confirmed',
location: 'Frankfurt Airport',
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
departure_airport: 'FRA', arrival_airport: 'HND' },
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
local_date: day(-9), local_time: '13:05' },
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
local_date: day(-8), local_time: '08:25' },
],
}).catch(() => {})
// 10. A collection, populated from the trip's own places.
let collectionId: number | undefined
try {
const created = await call<{ id: number } | { collection: { id: number } }>(
api, 'post', '/api/addons/collections',
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
}
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
// 11. Journey. Entries are generated server-side from the trip, then filled in.
let journeyId: number | undefined
try {
const j = await call<{ id: number } | { journey: { id: number } }>(
api, 'post', '/api/journeys',
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
journeyId = 'id' in j ? j.id : j.journey.id
} catch { /* journey addon unavailable */ }
// 11b. Collab: chat, notes and polls.
//
// Chat is only convincing with more than one voice, and every collab
// write is attributed to the acting user — so messages and votes are
// posted as the members themselves, via their own bearer tokens, not as
// the admin. A single-speaker chat log would misrepresent the feature.
// Each member gets its OWN request context. Logging in through the shared
// one would set the trek_session cookie on it, and extractToken()
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
// Authorization header — so every later write, including the admin's,
// would silently be attributed to whoever logged in last.
const members: Record<string, APIRequestContext> = {}
for (const m of MEMBERS) {
if (!newContext) break
const anon = await newContext()
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
if (!res.ok()) { await anon.dispose(); continue }
const { token } = (await res.json()) as { token?: string }
await anon.dispose()
if (token) members[m.username] = await newContext(token)
}
/** The member's own context, or the admin's as a visible fallback. */
const as = (username: string): APIRequestContext => members[username] ?? api
const collab = `/api/trips/${tripId}/collab`
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
}
const pollRes = await api.post(`${collab}/polls`, {
data: {
question: 'Which day should we keep free for Nara?',
options: [short(-5), short(-4), short(-2)],
multiple: false,
},
})
if (pollRes.ok()) {
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
}
await api.post(`${collab}/polls`, {
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
}).catch(() => {})
const conversation: Array<[string, string]> = [
['admin', `Flights are booked, we land at Haneda 08:25 on the ${ordinal(-8)}.`],
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
['mira', SEED_CHAT.teamlab],
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
['admin', "It's included: kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
]
for (const [who, text] of conversation) {
const ctx = who === 'admin' ? api : as(who)
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
}
for (const ctx of Object.values(members)) await ctx.dispose()
// 12. Plugins, installed from the community registry.
//
// Registry install is the ONLY path that produces a representative
// screenshot. Dev-link and sideload both stamp the plugin card with a
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
// "Link a local plugin" row in the panel. Documenting either would show
// readers a UI they will never have.
//
// Needs network. If the registry is unreachable the plugin screenshots are
// skipped loudly rather than silently captured in a misleading state.
for (const id of ['koffi', 'trip-doctor']) {
const res = await api.post('/api/admin/plugins/install', { data: { id } })
if (!res.ok()) {
console.log(`PLUGIN INSTALL FAILED ${id} → ${res.status()} ${await res.text()}`)
continue
}
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
}
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
}
+105
View File
@@ -0,0 +1,105 @@
import { test, clearNotices } from './shot'
import type { Page } from '@playwright/test'
/**
* Settings and Admin tabs.
*
* Both pages use the shared PageSidebar with client-side tab state (no URL
* segment per tab), so each capture clicks its way in. Labels come from
* shared/src/i18n/en — note "General" is the tab the wiki still calls
* "Display", which is one of the corrections this screenshot run supports.
*/
async function openSidebarTab(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(600)
}
test.describe('user settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await clearNotices(page)
})
// Filename kept as UsrSettings.png — the wiki already references it.
test('general tab', async ({ page, shot }) => {
await openSidebarTab(page, 'General')
await shot.page_('UsrSettings')
})
test('appearance tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Appearance')
await shot.page_('UsrSettingsAppearance')
})
test('map tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Map')
await shot.page_('UsrSettingsMap')
})
test('notifications tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Notifications')
await shot.page_('NotifSettings')
})
test('offline tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Offline')
await shot.page_('SettingsOffline')
})
test('account tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Account')
await shot.page_('SettingsAccount')
})
})
test.describe('admin panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin')
await clearNotices(page)
})
test('users', async ({ page, shot }) => {
await openSidebarTab(page, 'Users')
await shot.page_('UsersAndInvites')
})
test('user defaults', async ({ page, shot }) => {
await openSidebarTab(page, 'User Defaults')
await shot.page_('AdminUserDefaults')
})
test('personalization', async ({ page, shot }) => {
await openSidebarTab(page, 'Personalization')
await shot.page_('CategoryManager')
})
test('addons', async ({ page, shot }) => {
await openSidebarTab(page, 'Addons')
await shot.page_('Addons-Overview')
})
test('plugins', async ({ page, shot }) => {
await openSidebarTab(page, 'Plugins')
await shot.page_('AdminPlugins')
})
test('github releases', async ({ page, shot }) => {
await openSidebarTab(page, 'GitHub')
await shot.page_('GithubReleases')
})
test('backup', async ({ page, shot }) => {
await openSidebarTab(page, 'Backup')
await shot.page_('Backup')
})
test('audit log', async ({ page, shot }) => {
await openSidebarTab(page, 'Audit')
await shot.page_('Audit')
})
test('admin panel overview', async ({ page, shot }) => {
await shot.page_('AdminPanel')
})
})
+147
View File
@@ -0,0 +1,147 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { E2E_SEED_FILE } from '../../playwright.config'
/**
* Shared plumbing for the documentation screenshot run (`npm run shots`).
*
* These are not assertions about behaviour — they drive the app to a known
* state and capture it for the wiki. They live behind their own Playwright
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
* never pays for them.
*
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
*/
// Playwright runs from the client workspace root, matching how
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
export const VIEWPORT = { width: 1440, height: 900 }
export const test = base.extend<{ shot: Shot }>({
// Overriding `page` (rather than doing this inside the `shot` fixture) is
// deliberate: fixtures initialise lazily, so a route registered in `shot`
// lands AFTER any beforeEach hook has already navigated — too late to
// intercept the config request.
page: async ({ page }, use) => {
await page.setViewportSize(VIEWPORT)
await hideDevOnlyUi(page)
await use(page)
},
shot: async ({ page }, use) => {
mkdirSync(OUT_DIR, { recursive: true })
await use(new Shot(page))
},
})
/**
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
* "Dev: Notifications" tab that no real deployment ever shows.
*
* Rewriting the response is the surgical fix. Flipping the server to
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
* HSTS header on localhost would upgrade the run to https and break it.
*/
async function hideDevOnlyUi(page: Page): Promise<void> {
await page.route('**/api/auth/app-config', async route => {
const res = await route.fetch()
const body = await res.json()
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
})
}
export { expect }
interface SeedIds { tripId: number; collectionId?: number | null; journeyId?: number | null }
let seedCache: SeedIds | null = null
/**
* The ids the seed project wrote for this run's database. Read on first use, not
* at import: Playwright loads every test file before the seed project has run,
* and the file is named after the API port, so a run on its own ports reads its own.
*/
export const seed: SeedIds = new Proxy({} as SeedIds, {
get: (_t, key: string) => {
seedCache ??= JSON.parse(readFileSync(path.join(process.cwd(), E2E_SEED_FILE), 'utf8')) as SeedIds
return seedCache[key as keyof SeedIds]
},
})
export class Shot {
constructor(private readonly page: Page) {}
/**
* Capture the full viewport. `name` is the target filename in wiki/assets/
* (without extension) so the mapping from screenshot to doc page is literal.
*/
async page_(name: string): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/** Capture a region of the page, for a detail with no element of its own (a card over the map). */
async region(name: string, clip: { x: number; y: number; width: number; height: number }): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`), clip })
}
/** Capture one element — preferred for dialogs, panels and cards. */
async element(name: string, target: Locator): Promise<void> {
await this.settle()
await expect(target).toBeVisible()
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/**
* Quiet the page before capturing: fonts loaded, images decoded, animations
* finished, no pending network. Without this, screenshots catch skeleton
* loaders and half-faded modals, which is exactly how the current wiki
* assets ended up inconsistent.
*/
private async settle(): Promise<void> {
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
// fully idle and an unbounded wait would burn the whole test timeout.
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
await this.page.evaluate(async () => { await document.fonts.ready })
await this.page.evaluate(async () => {
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res })),
)
})
// Let CSS transitions land (modal fade-in, sidebar slide).
await this.page.waitForTimeout(400)
}
}
/**
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
* but tolerant: on a seeded DB the notice may already be cleared.
*/
export async function clearNotices(page: Page): Promise<void> {
// The "what's new" card of a release lays an overlay over the whole page that
// takes every click; it may land a moment after the page, so give it that moment.
const releaseClose = page.locator('.rn-close')
if (await releaseClose.waitFor({ state: 'visible', timeout: 2_500 }).then(() => true, () => false)) {
await releaseClose.click().catch(() => {})
await page.locator('.rn-overlay').waitFor({ state: 'detached', timeout: 3_000 }).catch(() => {})
}
const next = page.getByRole('button', { name: /next/i })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { test as setup } from '@playwright/test'
import {
ensureBookingsFixtures, ensureCollabFixtures, ensureDayDetailFixtures, ensureFilesFixtures,
ensureListsFixtures, ensureMapFixtures, ensureTransportFixtures,
} from '../help/fixtures'
/**
* The demo trip as the wiki pictures show it: the seed plus the fixtures the help
* guides add, so the Bookings tab holds a booking of every kind, Files has
* documents, Collab has links and What's Next something ahead, and the days carry
* their stays and transports. Its own project between the seed and the capture:
* the help-media run adds these per guide and has to start from the bare seed.
*/
setup('fill the demo trip for the wiki pictures', async ({ page }) => {
await ensureDayDetailFixtures(page.request)
await ensureBookingsFixtures(page.request)
await ensureTransportFixtures(page.request)
await ensureMapFixtures(page.request)
await ensureFilesFixtures(page.request)
await ensureListsFixtures(page.request)
await ensureCollabFixtures(page.request)
})
+10 -3
View File
@@ -12,7 +12,10 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const dbFile = path.join(here, '.tmp', 'e2e.db')
// The port is in the name, so two runs on two port pairs keep two databases
// and can render different guide files at the same time.
const apiPort = process.env.E2E_API_PORT || '3001'
const dbFile = path.join(here, '.tmp', `e2e-${apiPort}.db`)
const serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
@@ -20,14 +23,18 @@ for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
}
// Build once (no watcher) — the resulting process is a single killable node.
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
// Several runs on their own port pairs share one dist, so they build it once
// beforehand and set E2E_SERVER_PREBUILT: a rebuild under a running server
// swaps its files mid-flight.
if (!process.env.E2E_SERVER_PREBUILT) execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
// playwright.config.ts picks the port; 3001 is the historical default.
PORT: process.env.E2E_API_PORT || '3001',
NODE_ENV: 'development',
}
+8 -2
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
@@ -6,12 +7,17 @@ import { test, expect } from '@playwright/test'
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.modal-backdrop')
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Planner ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
+116
View File
@@ -0,0 +1,116 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT.
//
// iOS Safari minimises its address bar only in reaction to the root scroller
// moving. V4 blocked that twice over: html was globally overflow:hidden and the
// mobile shell added a scroll container of its own, so the gesture never
// reached the viewport and the bar kept ~1/6th of the screen forever. The bar
// itself is not observable from page context, but the invariant it hangs off is:
// document.scrollingElement has to move.
//
// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no
// webkit project, the engine comes from this file-level device (defaultBrowserType
// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not
// a nicety: viewport propagation and the toolbar behaviour are Safari's.
//
// The file sorts last in the e2e run, so the trips it seeds cannot show up in
// another spec's dashboard assertions.
test.use({ ...devices['iPhone 14'] })
function readScroll() {
const el = document.scrollingElement as HTMLElement
return {
top: el.scrollTop,
scrollHeight: el.scrollHeight,
viewport: window.innerHeight,
htmlOverflow: getComputedStyle(document.documentElement).overflowY,
}
}
test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// The context has to be the one from the report: phone width, coarse pointer.
// If either is wrong, nothing below proves anything.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true)
// Enough trips for the dashboard to overflow a phone screen.
const stamp = Date.now()
for (let i = 1; i <= 20; i++) {
const res = await page.request.post('/api/trips', {
data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' },
})
expect(res.ok(), `seed trip ${i}`).toBeTruthy()
}
await page.reload()
await dismissSystemNotices(page)
await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 })
// 1. The root scroller is unlocked and the page is longer than the viewport.
const before = await page.evaluate(readScroll)
expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden')
expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport)
expect(before.top).toBe(0)
// 2. Scrolling moves the document, the signal Safari retracts its bar for.
await page.evaluate(() => window.scrollBy(0, 400))
const scrolled = await page.evaluate(readScroll)
expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0)
// 3. The brand tile scrolls the page back up (it used to walk up to the shell
// container, which no longer exists).
await page.getByRole('button', { name: 'TREK' }).click()
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
// 4. The shared body scroll lock: with the document as the scroller, an open
// sheet must freeze the page behind it and give the position back. The
// lock is body overflow: hidden, which reaches the viewport only while
// html stays overflow: visible (index.css), and there it stops the swipe.
// It does not stop a script: window.scrollBy moves an overflow: hidden
// viewport in every engine, so the lock is read where it takes effect.
await page.evaluate(() => window.scrollBy(0, 300))
const locked = await page.evaluate(readScroll)
expect(locked.top).toBeGreaterThan(0)
await page.getByRole('button', { name: 'New Trip' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
const lock = await page.evaluate(() => ({
body: getComputedStyle(document.body).overflowY,
html: getComputedStyle(document.documentElement).overflowY,
top: document.scrollingElement!.scrollTop,
}))
expect(lock.body, 'the open sheet locks body').toBe('hidden')
expect(lock.html, 'html stays visible, so the lock reaches the viewport').toBe('visible')
expect(lock.top, 'opening the sheet does not move the page').toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(await page.evaluate(() => getComputedStyle(document.body).overflowY), 'the lock is released').not.toBe('hidden')
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.evaluate(() => window.scrollBy(0, 200))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
// 5. Counter-probe: the map screens keep their own full-viewport layout. They
// deliberately do NOT scroll the document (and so keep the address bar), and
// the map must not collapse to zero height now that the shell has none.
await page.goto('/atlas')
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const atlas = await page.evaluate(() => {
const map = document.querySelector('.leaflet-container') as HTMLElement
window.scrollBy(0, 400)
return {
top: document.scrollingElement!.scrollTop,
mapHeight: map.getBoundingClientRect().height,
}
})
expect(atlas.top, 'the atlas does not move the document').toBe(0)
expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200)
})
+72 -2
View File
@@ -5,6 +5,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import trek from './scripts/lib/eslint-rules.mjs';
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
@@ -46,6 +47,11 @@ export default tseslint.config(
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: reactStub,
trek,
},
languageOptions: {
// Typed linting, for the rules that need to know a value is a promise.
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
'react/no-danger': 'off',
@@ -54,8 +60,12 @@ export default tseslint.config(
// --- Severities tuned to keep CI green on a codebase that was never linted ---
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
'react-hooks/rules-of-hooks': 'warn',
// A hook called conditionally is a crash waiting for the render that skips it.
// The rest of react-hooks v7's recommended set (the React Compiler's checks:
// state set in effects or render, refs read in render, impure renders,
// mutated props) as warnings, each count held by scripts/eslint-baseline.json.
...Object.fromEntries(Object.keys(reactHooks.configs['recommended-latest'].rules).map((rule) => [rule, 'warn'])),
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
@@ -73,6 +83,66 @@ export default tseslint.config(
'no-useless-escape': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
// --- Added later as warnings; lint:warnings freezes what exists and fails on each new one ---
// A promise nobody awaits or catches loses its failure, and an async
// handler where a void one is expected (onClick={async () => ...})
// rejects into nowhere.
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': ['warn', { checksVoidReturn: { attributes: true } }],
'trek/no-swallowed-catch': 'warn',
// A `!` silences the null the type admits instead of handling it.
'@typescript-eslint/no-non-null-assertion': 'warn',
// Whole-store subscriptions; the message points to useShallow.
'trek/store-without-selector': 'warn',
// window.__dragData, window.__addToast and window.dispatchEvent buses.
'trek/no-window-globals': 'warn',
'no-console': 'warn',
},
},
{
// Size and branching, in app code only. ESLint's defaults for complexity
// (20) and max-depth (4). Five parameters: a hook with more takes an
// options object. A function body may hold 120 lines in a .ts file, which
// is logic only; a .tsx component also holds its markup, which is long by
// nature, so it gets 300, still well under the god components this is
// meant to stop (LoginPage was one function of 1300 lines).
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
complexity: ['warn', 20],
'max-depth': ['warn', 4],
'max-params': ['warn', 5],
'max-lines-per-function': ['warn', { max: 120, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
files: ['src/**/*.tsx'],
ignores: ['src/**/*.test.tsx'],
rules: {
'max-lines-per-function': ['warn', { max: 300, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
// react-dom/server was worth ~190 KB raw / 57 KB gzip in a chunk three lazy
// routes share — including the Leaflet renderer, which is the default — for
// output that is always a single <svg>. utils/iconMarkup.ts does that job
// without Fizz, and this keeps the import from creeping back: nothing else
// would notice, the build stays green and the app keeps working.
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'react-dom/server',
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
}],
'no-restricted-syntax': ['error', {
selector: "ImportExpression > Literal[value=/^react-dom\\u002F(server|static)/]",
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
},
},
);
+8 -9
View File
@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
same reasons, and ahead of the entry, which it has to watch. -->
<script src="/shell-guard.js"></script>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -15,15 +22,7 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Fonts are bundled from @fontsource in src/main.tsx, see the note there. -->
</head>
<body>
<div id="root"></div>
+80 -47
View File
@@ -1,12 +1,16 @@
{
"name": "@trek/client",
"version": "3.1.3",
"version": "4.3.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -16,74 +20,103 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:check": "eslint .",
"lint:warnings": "node scripts/eslint-ratchet.mjs",
"lint:pages": "node scripts/check-page-pattern.mjs",
"lint:rtl": "node scripts/rtl-lint.mjs",
"lint:size": "node scripts/size-lint.mjs",
"lint:format": "node scripts/format-lint.mjs",
"lint:layers": "node scripts/layer-lint.mjs",
"lint:offline": "node scripts/offline-lint.mjs",
"lint:dup": "node scripts/dup-lint.mjs",
"lint:pairs": "node scripts/pairs-lint.mjs",
"lint:skips": "node scripts/skip-lint.mjs",
"lint:strict": "node scripts/strict-lint.mjs",
"lint:i18n-keys": "node scripts/i18n-keys.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"help:media": "node e2e/help/run.mjs",
"help:media:promote": "node e2e/help/promote.mjs",
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"heic-to": "^1.4.2",
"axios": "^1.20.0",
"dexie": "^4.4.6",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.5.2",
"iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"react-router": "^7.18.4",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
"tz-lookup": "^6.1.25",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@jscpd/core": "^4.2.5",
"@jscpd/tokenizer": "^4.2.6",
"@playwright/test": "^1.63.0",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.9.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"jsdom": "^29.1.1",
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prettier": "^3.9.8",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vitest": "^4.1.11"
}
}
+91 -4
View File
@@ -9,6 +9,34 @@ import { defineConfig, devices } from '@playwright/test'
* /ws to the backend. Tests run serially against one worker so they share the
* single seeded database deterministically.
*/
/**
* Ports. The defaults are what `npm run e2e` has always used. `npm run
* help:media` (e2e/help/run.mjs) sets E2E_WEB_PORT/E2E_API_PORT to a second
* pair so it can record while `npm run dev` keeps serving 5173/3001 to whoever
* is watching. Environment only, no argv sniffing: Playwright's workers load
* this file again with different arguments, and every process must agree.
* vite.config.js honours TREK_DEV_PORT and TREK_DEV_API for the same reason.
*/
const WEB_PORT = Number(process.env.E2E_WEB_PORT) || 5173
const API_PORT = Number(process.env.E2E_API_PORT) || 3001
export const E2E_BASE_URL = `http://localhost:${WEB_PORT}`
/**
* The session and the seed's ids belong to the database the run boots, which is
* named after its API port (e2e/server-launch.mjs). Two runs on two port pairs
* therefore share nothing, and the default port keeps the old file names.
*/
export const E2E_STATE_FILE = `e2e/.tmp/state-${API_PORT}.json`
export const E2E_SEED_FILE = `e2e/.tmp/seed-${API_PORT}.json`
// The day the seed and the pictures are relative to (e2e/dates.ts). Fixed here
// for every project and worker that loads this file, so a run that crosses
// midnight still agrees with itself; `help:media` sets it before this runs.
if (!process.env.E2E_PICTURE_DAY) {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
process.env.E2E_PICTURE_DAY = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
@@ -19,7 +47,7 @@ export default defineConfig({
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
baseURL: E2E_BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -32,16 +60,74 @@ export default defineConfig({
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['setup'],
},
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
// run by its own testMatch — these capture artwork for wiki/assets/, they
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
// Populates the demo trip the screenshots are taken of. Separate project so
// it runs exactly once, between auth and capture.
{
name: 'seed',
testMatch: /seed\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['setup'],
},
// The wiki pictures show the demo trip filled in with the fixtures the help
// guides use (a booking of every kind, documents, links); help-media adds
// them guide by guide and so starts from the bare seed.
{
name: 'shot-fixtures',
testMatch: /shots\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['seed'],
},
{
name: 'screenshots',
testMatch: /\.shot\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: E2E_STATE_FILE,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
dependencies: ['shot-fixtures'],
},
// Help-center media (`npm run help:media`): pictures and walkthroughs for
// the in-app guides, generated from the guide definitions in src/help/.
// Same seed as the wiki screenshots; its own match so neither run pays
// for the other. Serial and generous on time: a walkthrough is paced for
// a human to follow.
{
name: 'help-media',
testMatch: /\.guide\.ts/,
// Four minutes. A guide is a whole task, and a few of them wait on the
// network more than once: the nearby-places one runs two POI searches
// against a public index and opens the place form on top of them, which
// does not fit in the two and a half minutes this used to allow.
timeout: 240_000,
use: {
...devices['Desktop Chrome'],
// The full Chrome for Testing build rather than the headless shell the
// other projects run on. Only the full build carries the PDF viewer,
// and the Files tab's preview embeds a PDF as <object>, which the
// shell leaves as a "Download PDF" link. Still headless.
channel: 'chromium',
storageState: E2E_STATE_FILE,
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: 2,
},
dependencies: ['seed'],
},
],
webServer: [
{
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
port: API_PORT,
env: { E2E_API_PORT: String(API_PORT) },
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
@@ -49,7 +135,8 @@ export default defineConfig({
},
{
command: 'npm run dev',
port: 5173,
port: WEB_PORT,
env: { TREK_DEV_PORT: String(WEB_PORT), TREK_DEV_API: `http://localhost:${API_PORT}` },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-tower-control-icon lucide-tower-control">
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
<path d="M8 13v9" />
<path d="M16 22v-9" />
<path d="m9 6 1 7" />
<path d="m15 6-1 7" />
<path d="M12 6V2" />
<path d="M13 2h-2" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

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